Compare commits
30 Commits
70a813edfa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 33c9c39676 | |||
| 39ec958f04 | |||
| 097d46886d | |||
| 0dcc0bc823 | |||
| c4f666209c | |||
| 9a5e302a26 | |||
| 88fa5d19c8 | |||
| c65aeb4bac | |||
| 46e2099542 | |||
| 35168b9b83 | |||
| 0676f53280 | |||
| f54c557e70 | |||
| b232b4b3d3 | |||
| 70cb664246 | |||
| b4e919502f | |||
| df08682bed | |||
| 420f045033 | |||
| 9bacb8092d | |||
| e8beb7d4f3 | |||
| 0d28f69320 | |||
| 05dc91410f | |||
| c59aea42ef | |||
| b5ebbfb748 | |||
| 7de1086725 | |||
| f3897907a3 | |||
| 6265ffa894 | |||
| c69c028028 | |||
| d698f70c4f | |||
| b1c9a42810 | |||
| 7d3140334e |
@@ -0,0 +1,13 @@
|
||||
# Container-owned data: unreadable by the build user, and stat'ing it fails the
|
||||
# context walk outright.
|
||||
.volumes/
|
||||
backups/
|
||||
|
||||
.git/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local working backups.
|
||||
.bak-*/
|
||||
**/.bak-*/
|
||||
@@ -1,6 +1,9 @@
|
||||
# --- core ---
|
||||
# Set APP_BASE_URL to the public https URL when behind the reverse proxy.
|
||||
APP_BASE_URL=http://localhost:8787
|
||||
# In-network address external services use to call back into the app.
|
||||
# docker-compose.yml sets this to http://app:8787; defaults to APP_BASE_URL.
|
||||
#APP_INTERNAL_URL=
|
||||
# Comma-separated CIDRs of trusted reverse proxies, e.g. 172.16.0.0/12,10.0.0.0/8.
|
||||
# Empty = trust no proxy headers.
|
||||
TRUSTED_PROXY_CIDRS=
|
||||
@@ -21,6 +24,12 @@ ADMIN_INITIAL_PASSWORD=change-me-now
|
||||
ATOMIZER_BASE_URL=http://atomizer-mock:8090
|
||||
ATOMIZER_TOKEN=change-me-atomizer
|
||||
ATOMIZER_TIMEOUT_SEC=120
|
||||
# Optional: serve Subdivide from the Anypreta Issue Atomizer instead of the
|
||||
# §5.1 service above. Both vars are required to enable it. Extend and Summarize
|
||||
# have no counterpart in that API and always stay on ATOMIZER_BASE_URL, so the
|
||||
# service above must keep running either way.
|
||||
ATOMIZER_REMOTE_BASE_URL=
|
||||
ATOMIZER_REMOTE_API_KEY=
|
||||
WORK_PERFORMER_BASE_URL=http://work-performer:8091
|
||||
WORK_PERFORMER_TOKEN=change-me-performer
|
||||
# Job submission/polling only; jobs themselves are async.
|
||||
|
||||
@@ -3,3 +3,8 @@ bin/
|
||||
backups/
|
||||
*.test
|
||||
coverage.out
|
||||
|
||||
# Docker volume data (bind-mounted under the repo; never committed)
|
||||
/.volumes/
|
||||
.env.bak*
|
||||
.env.local
|
||||
|
||||
@@ -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 5–12 (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).
|
||||
|
||||
@@ -4,6 +4,7 @@ GO ?= go
|
||||
|
||||
build:
|
||||
$(GO) build ./...
|
||||
cd services/atomizer && $(GO) build ./...
|
||||
|
||||
run:
|
||||
$(GO) run ./cmd/app
|
||||
@@ -16,19 +17,32 @@ test:
|
||||
test-integration:
|
||||
$(GO) test -tags=integration ./...
|
||||
|
||||
# Requires the mock services running:
|
||||
# docker compose --profile mocks up -d --build atomizer-mock work-performer
|
||||
test-contract:
|
||||
ATOMIZER_TOKEN=$$(grep '^ATOMIZER_TOKEN=' .env | cut -d= -f2-) \
|
||||
WORK_PERFORMER_TOKEN=$$(grep '^WORK_PERFORMER_TOKEN=' .env | cut -d= -f2-) \
|
||||
$(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/
|
||||
|
||||
lint:
|
||||
@unformatted=$$(gofmt -l .); if [ -n "$$unformatted" ]; then \
|
||||
@unformatted=$$(gofmt -l $$(git ls-files '*.go')); if [ -n "$$unformatted" ]; then \
|
||||
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
|
||||
$(GO) vet ./...
|
||||
|
||||
# Requires Mongo published on loopback (test overlay) or MONGO_SEED_URI.
|
||||
seed:
|
||||
@echo "seed script lands in phase 12 (scripts/seed.go)"; exit 1
|
||||
$(GO) run ./scripts
|
||||
|
||||
smoke:
|
||||
./scripts/smoke.sh
|
||||
|
||||
# mongodump/mongorestore through the mongo container into ./backups (§11.23)
|
||||
backup:
|
||||
@echo "backup target lands in phase 12"; exit 1
|
||||
@mkdir -p backups
|
||||
docker compose exec -T mongo mongodump --archive --db=bountyboard > backups/bountyboard-$$(date +%Y%m%d-%H%M%S).archive
|
||||
@ls -lh backups/ | tail -1
|
||||
|
||||
# usage: make restore FILE=backups/bountyboard-20260612-120000.archive
|
||||
restore:
|
||||
@echo "restore target lands in phase 12"; exit 1
|
||||
@test -n "$(FILE)" || (echo "usage: make restore FILE=backups/<archive>"; exit 1)
|
||||
docker compose exec -T mongo mongorestore --archive --drop < $(FILE)
|
||||
|
||||
@@ -9,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Package api embeds the hand-written OpenAPI document (§6).
|
||||
package api
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed openapi.yaml
|
||||
var OpenAPI []byte
|
||||
@@ -0,0 +1,289 @@
|
||||
openapi: "3.1.0"
|
||||
info:
|
||||
title: Bounty Board API
|
||||
version: "1.0"
|
||||
description: |
|
||||
Consulting work-management platform API (spec §6). All endpoints are
|
||||
session-authenticated (httpOnly cookie `bb_session`); mutations also
|
||||
require the CSRF double-submit header `X-CSRF-Token` mirroring the
|
||||
`bb_csrf` cookie. Errors use the envelope
|
||||
`{"error":{"code":"…","message":"…"}}`. Pagination: `?limit=&cursor=<ulid>`.
|
||||
servers:
|
||||
- url: /api/v1
|
||||
tags:
|
||||
- {name: auth}
|
||||
- {name: admin}
|
||||
- {name: consultant}
|
||||
- {name: developer}
|
||||
- {name: tasks}
|
||||
- {name: messaging}
|
||||
- {name: shared}
|
||||
- {name: internal}
|
||||
|
||||
paths:
|
||||
/auth/register:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Self-register a developer account
|
||||
requestBody: {$ref: "#/components/requestBodies/Register"}
|
||||
responses:
|
||||
"201": {description: Account created and session started}
|
||||
"409": {description: Email already registered}
|
||||
/auth/login:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Local login
|
||||
requestBody: {$ref: "#/components/requestBodies/Login"}
|
||||
responses:
|
||||
"200": {description: "Session cookies set; body: user + mustChangePassword"}
|
||||
"401": {description: Invalid credentials}
|
||||
"429": {description: Rate limited (10 attempts / 15 min / IP+email)}
|
||||
/auth/logout:
|
||||
post: {tags: [auth], summary: Revoke the current session, responses: {"204": {description: Logged out}}}
|
||||
/auth/logout-all:
|
||||
post: {tags: [auth], summary: Revoke every session of the current user, responses: {"200": {description: Count of revoked sessions}}}
|
||||
/auth/change-password:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Change the local password (revokes other sessions)
|
||||
responses: {"204": {description: Changed}, "401": {description: Wrong current password}}
|
||||
/auth/forgot:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Request a password-reset email (SMTP required)
|
||||
responses: {"200": {description: Uniform response}, "503": {description: SMTP not configured}}
|
||||
/auth/reset:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Set a new password with a one-shot token
|
||||
responses: {"204": {description: Password set}, "400": {description: Invalid or expired token}}
|
||||
/auth/me:
|
||||
get: {tags: [auth], summary: Current user, responses: {"200": {description: User profile + flags}}}
|
||||
/auth/oidc/login:
|
||||
get: {tags: [auth], summary: Start the OIDC code flow with PKCE (302 to the IdP), responses: {"302": {description: Redirect}}}
|
||||
/auth/oidc/callback:
|
||||
get: {tags: [auth], summary: OIDC redirect URI (links by verified email or creates a developer), responses: {"302": {description: Redirect home or to /login?error=…}}}
|
||||
|
||||
/admin/users:
|
||||
get:
|
||||
tags: [admin]
|
||||
summary: List/search users
|
||||
parameters:
|
||||
- {name: q, in: query, schema: {type: string}}
|
||||
- {name: role, in: query, schema: {type: string, enum: [admin, consultant, developer]}}
|
||||
responses: {"200": {description: Users + nextCursor}}
|
||||
/admin/users/{id}:
|
||||
patch:
|
||||
tags: [admin]
|
||||
summary: "Update roles / disabled / name / force password reset"
|
||||
responses: {"200": {description: Updated user}, "400": {description: Self-lockout guard}, "409": {description: Version conflict}}
|
||||
delete: {tags: [admin], summary: Delete a user and their sessions, responses: {"204": {description: Deleted}}}
|
||||
/admin/customers:
|
||||
get: {tags: [admin], summary: List customers (includeArchived=true optional), responses: {"200": {description: Customers (credentials never returned)}}}
|
||||
post:
|
||||
tags: [admin]
|
||||
summary: Create a customer with per-system credentials (encrypted at rest)
|
||||
requestBody: {$ref: "#/components/requestBodies/Customer"}
|
||||
responses: {"201": {description: Created}, "409": {description: Name taken}}
|
||||
/admin/customers/{id}:
|
||||
get: {tags: [admin], summary: Get one customer, responses: {"200": {description: Customer}}}
|
||||
patch: {tags: [admin], summary: Partial update (credentials rotate when supplied), responses: {"200": {description: Updated}, "409": {description: Version conflict}}}
|
||||
delete: {tags: [admin], summary: Delete (blocked while tasks exist), responses: {"204": {description: Deleted}, "409": {description: Has tasks — archive instead}}}
|
||||
/admin/customers/test-connection:
|
||||
post: {tags: [admin], summary: Test unsaved credentials (wizard), responses: {"200": {description: "{ok, latencyMs, error?}"}}}
|
||||
/admin/customers/{id}/test-connection:
|
||||
post: {tags: [admin], summary: Test the stored connection, responses: {"200": {description: "{ok, latencyMs, error?}"}}}
|
||||
/admin/customers/{id}/sync-now:
|
||||
post: {tags: [admin], summary: Trigger an immediate sync poll, responses: {"202": {description: Queued}}}
|
||||
/admin/settings:
|
||||
get: {tags: [admin], summary: Runtime settings, responses: {"200": {description: Settings}}}
|
||||
patch: {tags: [admin], summary: Update runtime settings (atomizer URL override, branding, …), responses: {"200": {description: Updated settings}}}
|
||||
/admin/audit-log:
|
||||
get:
|
||||
tags: [admin]
|
||||
summary: Audit log of privileged mutations
|
||||
parameters:
|
||||
- {name: actorId, in: query, schema: {type: string}}
|
||||
- {name: entityId, in: query, schema: {type: string}}
|
||||
- {name: cursor, in: query, schema: {type: string}}
|
||||
responses: {"200": {description: Entries + nextCursor}}
|
||||
/admin/service-status:
|
||||
get: {tags: [admin], summary: "Health/latency/breaker for atomizer + work performer, sync workers, job queue (§11.17)", responses: {"200": {description: Status payload}}}
|
||||
|
||||
/consultant/board:
|
||||
get:
|
||||
tags: [consultant]
|
||||
summary: Atomization board (imported…claim_requested tasks of own customers)
|
||||
parameters:
|
||||
- {name: customerId, in: query, schema: {type: string}}
|
||||
- {name: status, in: query, schema: {type: string}}
|
||||
responses: {"200": {description: Customers + tasks}}
|
||||
/consultant/reviews:
|
||||
get: {tags: [consultant], summary: Tasks in review for own customers, responses: {"200": {description: Tasks}}}
|
||||
/consultant/pool:
|
||||
get: {tags: [consultant], summary: Global developer pool with membership flags, responses: {"200": {description: Developers}}}
|
||||
post: {tags: [consultant], summary: Add a developer to my pool, responses: {"201": {description: Added}, "409": {description: Already in pool}}}
|
||||
/consultant/pool/{developerId}:
|
||||
delete: {tags: [consultant], summary: Remove a developer from my pool, responses: {"204": {description: Removed}}}
|
||||
/consultant/metrics:
|
||||
get:
|
||||
tags: [consultant]
|
||||
summary: "Aggregations per developer/customer + lead time + board depth (§6.2); format=csv exports the ledger"
|
||||
parameters:
|
||||
- {name: customerId, in: query, schema: {type: string}}
|
||||
- {name: developerId, in: query, schema: {type: string}}
|
||||
- {name: from, in: query, schema: {type: string, format: date}}
|
||||
- {name: to, in: query, schema: {type: string, format: date}}
|
||||
- {name: format, in: query, schema: {type: string, enum: [csv]}}
|
||||
responses: {"200": {description: Metrics JSON or CSV}}
|
||||
|
||||
/board:
|
||||
get:
|
||||
tags: [developer]
|
||||
summary: Bounty board (published tasks of consultants who pooled me)
|
||||
parameters:
|
||||
- {name: customerId, in: query, schema: {type: string}}
|
||||
- {name: q, in: query, schema: {type: string}, description: Mongo text search}
|
||||
- {name: minBounty, in: query, schema: {type: number}}
|
||||
- {name: sort, in: query, schema: {type: string, enum: ["", bounty]}}
|
||||
responses: {"200": {description: Tasks + customers}}
|
||||
/my-tasks:
|
||||
get: {tags: [developer], summary: My kanban (assigned…approved), responses: {"200": {description: Tasks}}}
|
||||
/developer/metrics:
|
||||
get:
|
||||
tags: [developer]
|
||||
summary: "Own §6.2 metrics; format=csv exports the ledger"
|
||||
parameters:
|
||||
- {name: from, in: query, schema: {type: string, format: date}}
|
||||
- {name: to, in: query, schema: {type: string, format: date}}
|
||||
- {name: format, in: query, schema: {type: string, enum: [csv]}}
|
||||
responses: {"200": {description: Metrics JSON or CSV}}
|
||||
/leaderboard:
|
||||
get: {tags: [shared], summary: Top developers by bounty (opt-out honored), responses: {"200": {description: Leaderboard}}}
|
||||
|
||||
/tasks/{id}:
|
||||
get: {tags: [tasks], summary: Task detail incl. timeline (role-scoped), responses: {"200": {description: Task}, "403": {description: No access}}}
|
||||
patch: {tags: [consultant], summary: "Edit title/description/AC/coefficient/budget (version-checked, bounty recomputed; cascadeBudget optional)", responses: {"200": {description: Updated task}, "409": {description: Conflict or immutable}}}
|
||||
/tasks/{id}/subdivide:
|
||||
post: {tags: [consultant], summary: "Queue AI subdivision (§5.1); re-run needs confirmReplace", responses: {"202": {description: "{jobId}"}, "409": {description: Bad status / confirm required}}}
|
||||
/tasks/{id}/extend:
|
||||
post: {tags: [consultant], summary: Queue AI extension (note required), responses: {"202": {description: "{jobId}"}}}
|
||||
/tasks/{id}/publish:
|
||||
post: {tags: [consultant], summary: Publish an atomized task to the board, responses: {"200": {description: Task}, "409": {description: Illegal transition}}}
|
||||
/tasks/publish:
|
||||
post: {tags: [consultant], summary: "Bulk publish {ids:[…]}", responses: {"200": {description: "{published, failed}"}}}
|
||||
/tasks/archive:
|
||||
post: {tags: [consultant], summary: "Bulk archive {ids:[…]}", responses: {"200": {description: "{archived, failed}"}}}
|
||||
/tasks/{id}/archive:
|
||||
post: {tags: [tasks], summary: Archive (consultant/admin), responses: {"204": {description: Archived}}}
|
||||
/tasks/{id}/claim:
|
||||
post: {tags: [developer], summary: Request assignment (optional pitch note), responses: {"204": {description: Requested}, "409": {description: Already requested / not open}}}
|
||||
/tasks/{id}/claim/withdraw:
|
||||
post: {tags: [developer], summary: Withdraw my claim (back to published when none remain), responses: {"204": {description: Withdrawn}}}
|
||||
/tasks/{id}/approve-claim:
|
||||
post: {tags: [consultant], summary: "Approve one developer {developerId}; others are declined + notified", responses: {"204": {description: Assigned}}}
|
||||
/tasks/{id}/decline-claim:
|
||||
post: {tags: [consultant], summary: Decline one claim, responses: {"204": {description: Declined}}}
|
||||
/tasks/{id}/assign-ai:
|
||||
post: {tags: [consultant], summary: "Create a Work Performer job (§5.2) {context:{repositoryUrl,branch,instructions}}", responses: {"202": {description: "{jobId}"}, "502": {description: Performer rejected the job}}}
|
||||
/tasks/{id}/unassign:
|
||||
post: {tags: [consultant], summary: Return an assigned/in-progress task to the board, responses: {"204": {description: Unassigned}}}
|
||||
/tasks/{id}/start:
|
||||
post: {tags: [developer], summary: Start work (assigned or changes_requested), responses: {"204": {description: In progress}}}
|
||||
/tasks/{id}/submit-review:
|
||||
post: {tags: [developer], summary: Submit for review, responses: {"204": {description: In review}}}
|
||||
/tasks/{id}/abandon:
|
||||
post: {tags: [developer], summary: Abandon back to the board, responses: {"204": {description: Published}}}
|
||||
/tasks/{id}/comments:
|
||||
post: {tags: [tasks], summary: Add a sanitized comment (@mentions notify), responses: {"201": {description: Comment}}}
|
||||
/tasks/{id}/time:
|
||||
post: {tags: [developer], summary: "Log time {minutes, note}", responses: {"201": {description: Entry}}}
|
||||
/tasks/{id}/review:
|
||||
post: {tags: [consultant], summary: "Review {decision: approve|request_changes, note, checklist[]}; approve freezes the bounty into bountyAwards", responses: {"204": {description: Reviewed}}}
|
||||
|
||||
/conversations:
|
||||
get: {tags: [messaging], summary: My conversations with unread counts, responses: {"200": {description: Conversations}}}
|
||||
post: {tags: [messaging], summary: "Create dm (deduplicated) / group / project conversation", responses: {"200": {description: Existing dm}, "201": {description: Created}}}
|
||||
/conversations/{id}/messages:
|
||||
get:
|
||||
tags: [messaging]
|
||||
summary: Message history (newest page; cursor pages backwards)
|
||||
parameters: [{name: cursor, in: query, schema: {type: string}}]
|
||||
responses: {"200": {description: Messages chronological}}
|
||||
post: {tags: [messaging], summary: "Send {body (sanitized rich text), attachments:[fileIds]}", responses: {"201": {description: Message}}}
|
||||
/conversations/{id}/read:
|
||||
post: {tags: [messaging], summary: Mark all messages read, responses: {"200": {description: "{marked}"}}}
|
||||
|
||||
/files:
|
||||
post: {tags: [shared], summary: "Upload (multipart field `file`; scope=chat default, task for consultants)", responses: {"201": {description: "{fileId, name, mimeType, size, isImage}"}, "413": {description: Exceeds MAX_UPLOAD_MB}}}
|
||||
/profile:
|
||||
get: {tags: [shared], summary: Own profile, responses: {"200": {description: User}}}
|
||||
patch: {tags: [shared], summary: "Partial profile update (name, bio, contact, extra, settings.theme/leaderboardOptOut); optional version → 409 on conflict", responses: {"200": {description: Updated user}}}
|
||||
/profile/avatar:
|
||||
post: {tags: [shared], summary: Upload avatar (must sniff as an image), responses: {"200": {description: "{fileId}"}}}
|
||||
/users:
|
||||
get: {tags: [shared], summary: User directory search (name/email) for messaging, responses: {"200": {description: Users}}}
|
||||
/users/{id}/card:
|
||||
get: {tags: [shared], summary: Public profile card (hover cards), responses: {"200": {description: Card}}}
|
||||
/notifications:
|
||||
get: {tags: [shared], summary: My notifications + unread count, responses: {"200": {description: Notifications}}}
|
||||
/notifications/read:
|
||||
post: {tags: [shared], summary: "Mark read {ids:[]} (empty = all)", responses: {"200": {description: "{marked}"}}}
|
||||
/service-health:
|
||||
get: {tags: [shared], summary: Atomizer + work-performer health/breaker for UI gating, responses: {"200": {description: Health}}}
|
||||
|
||||
/internal/work-results:
|
||||
post:
|
||||
tags: [internal]
|
||||
summary: "Work Performer callback (§5.2): HMAC `X-Signature` over the raw body with WORK_PERFORMER_TOKEN; idempotent by jobId; artifacts ingested into GridFS"
|
||||
responses:
|
||||
"200": {description: Accepted or duplicate_ignored}
|
||||
"401": {description: Bad signature}
|
||||
|
||||
components:
|
||||
requestBodies:
|
||||
Register:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email, name, password]
|
||||
properties:
|
||||
email: {type: string, format: email}
|
||||
name: {type: string}
|
||||
password: {type: string, minLength: 8}
|
||||
Login:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email, password]
|
||||
properties:
|
||||
email: {type: string}
|
||||
password: {type: string}
|
||||
Customer:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, type]
|
||||
properties:
|
||||
name: {type: string}
|
||||
type: {type: string, enum: [jira, azure_devops, youtrack, wekan, demo]}
|
||||
baseUrl: {type: string}
|
||||
projectKey: {type: string}
|
||||
pollIntervalSec: {type: integer, minimum: 10}
|
||||
defaultBudget: {type: number}
|
||||
consultantIds: {type: array, items: {type: string}}
|
||||
credentials:
|
||||
type: object
|
||||
description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · wekan: {username, password} (projectKey = board id) · demo: {}"
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
+10
-1
@@ -86,6 +86,8 @@ func run() error {
|
||||
})
|
||||
srv.SetWSHandler(hub.Handle)
|
||||
srv.SetPublishFn(hub.Broadcast)
|
||||
srv.SetSendTo(hub.SendTo)
|
||||
hub.SetInbound(srv.HandleInboundWS)
|
||||
|
||||
// Atomizer client honoring the admin base-URL override per call.
|
||||
atomClient := atomize.New(func() string {
|
||||
@@ -96,6 +98,13 @@ func run() error {
|
||||
}
|
||||
return cfg.AtomizerBaseURL
|
||||
}, cfg.AtomizerToken, cfg.AtomizerTimeout)
|
||||
if cfg.AtomizerRemoteBaseURL != "" {
|
||||
// Subdivide is served by the Anypreta Issue Atomizer; Extend and
|
||||
// Summarize have no counterpart there and stay on ATOMIZER_BASE_URL.
|
||||
atomClient.UseRemote(cfg.AtomizerRemoteBaseURL, cfg.AtomizerRemoteAPIKey, cfg.AtomizerTimeout)
|
||||
log.Info("atomizer: subdivide served by remote backend",
|
||||
"url", cfg.AtomizerRemoteBaseURL, "extend_summarize", cfg.AtomizerBaseURL)
|
||||
}
|
||||
srv.SetAtomizerInfo(atomClient)
|
||||
srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy})
|
||||
|
||||
@@ -107,7 +116,7 @@ func run() error {
|
||||
|
||||
// Background job queue + atomization handlers.
|
||||
runner := jobs.NewRunner(st, log, reg, 4)
|
||||
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL,
|
||||
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppInternalURL,
|
||||
[]byte(cfg.SessionSecret), srv.Publish)
|
||||
runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide)
|
||||
runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend)
|
||||
|
||||
+56
-3
@@ -13,7 +13,8 @@ services:
|
||||
# Mongo is reachable only on the compose network; never published to the
|
||||
# host (§12).
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
# data lives in-repo (gitignored, dot-prefixed so go tooling skips it)
|
||||
- ./.volumes/mongo:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
@@ -27,8 +28,16 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
MONGO_URI: mongodb://mongo:27017
|
||||
# in-network address handed to the external services for callbacks
|
||||
# and signed attachment URLs (§5.2 example: http://app:8787/…)
|
||||
APP_INTERNAL_URL: http://app:8787
|
||||
# NOTE: published on all interfaces to match this host's pattern (gitea,
|
||||
# outline, wekan are exposed the same way; docker-published ports bypass
|
||||
# UFW). The spec-default loopback-only binding is the commented line —
|
||||
# restore it once the app sits behind the reverse proxy exclusively.
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-8787}:8787"
|
||||
- "${APP_PORT:-8787}:8787"
|
||||
# - "127.0.0.1:${APP_PORT:-8787}:8787"
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
@@ -39,5 +48,49 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# --- 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:
|
||||
mongo-data:
|
||||
- ${HOME}/.claude:/home/node/.claude
|
||||
- ${HOME}/.claude.json:/home/node/.claude.json
|
||||
- ./.volumes/work:/work
|
||||
ports:
|
||||
- "127.0.0.1:8091:8091"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8091/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package atomize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"bountyboard/internal/extsvc"
|
||||
)
|
||||
|
||||
// Backend for the Anypreta "Issue Atomizer" API (OpenAPI 3.1, v0.1.0). Its
|
||||
// model is two-level — issue → features → tasks — where ours is one-level
|
||||
// (task → children), and only *features* carry an effort weight
|
||||
// (`estimation`); its tasks carry none at all. A board child therefore maps to
|
||||
// a Feature and Subdivide calls POST /v1/atomize-issue.
|
||||
//
|
||||
// Extend and Summarize have no counterpart here (/v1/extend-feature-tasks adds
|
||||
// tasks *inside* one feature and returns the whole set), so those stay on the
|
||||
// §5.1 service. The API declares no security scheme; it authenticates with
|
||||
// Authorization: Bearer <key>.
|
||||
|
||||
// Field caps from the spec — exceeded values are 422s, so trim rather than fail.
|
||||
const (
|
||||
maxIssueLen = 50000
|
||||
maxGuidanceLen = 10000
|
||||
maxSystemIDLen = 256
|
||||
maxSystemDesc = 2000
|
||||
)
|
||||
|
||||
type apComponent struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
SubComponents []apComponent `json:"sub_components,omitempty"`
|
||||
}
|
||||
|
||||
type apSystem struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
Components []apComponent `json:"components,omitempty"`
|
||||
}
|
||||
|
||||
type apAtomizeIssueRequest struct {
|
||||
System apSystem `json:"system"`
|
||||
Issue string `json:"issue"`
|
||||
GuidanceNote string `json:"guidance_note,omitempty"`
|
||||
}
|
||||
|
||||
type apCriterion struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type apFeature struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
StakeholderSummary string `json:"stakeholder_summary"`
|
||||
Description string `json:"description"`
|
||||
AcceptanceCriteria []apCriterion `json:"acceptance_criteria"`
|
||||
Priority int `json:"priority"`
|
||||
RelatedComponents []string `json:"related_components"`
|
||||
DependsOn []string `json:"depends_on"`
|
||||
// Estimation is optional and nullable in the spec; observed to sum to 1
|
||||
// across a feature set, but that is not promised.
|
||||
Estimation *float64 `json:"estimation"`
|
||||
}
|
||||
|
||||
type apAtomizeIssueResponse struct {
|
||||
FeatureSet struct {
|
||||
Features []apFeature `json:"features"`
|
||||
} `json:"feature_set"`
|
||||
}
|
||||
|
||||
// remote is the Anypreta-backed Atomize path. It reuses extsvc for the bearer
|
||||
// auth, retry and circuit breaker; the base URL carries the /v1 prefix so
|
||||
// extsvc's /healthz probe lands on the API's /v1/healthz.
|
||||
type remote struct {
|
||||
c *extsvc.Client
|
||||
}
|
||||
|
||||
func newRemote(baseURL, apiKey string, timeout time.Duration) *remote {
|
||||
base := strings.TrimRight(baseURL, "/") + "/v1"
|
||||
return &remote{c: extsvc.NewClient("atomizer-remote",
|
||||
func() string { return base }, apiKey, timeout)}
|
||||
}
|
||||
|
||||
// Atomize maps a subdivide request onto POST /v1/atomize-issue and maps the
|
||||
// returned features back onto board children.
|
||||
func (r *remote) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
|
||||
body := apAtomizeIssueRequest{
|
||||
System: systemFor(req),
|
||||
Issue: truncate(issueText(req), maxIssueLen),
|
||||
GuidanceNote: truncate(guidanceNote(req), maxGuidanceLen),
|
||||
}
|
||||
var resp apAtomizeIssueResponse
|
||||
if err := r.c.Call(ctx, http.MethodPost, "/atomize-issue", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
features := resp.FeatureSet.Features
|
||||
if len(features) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty feature set", ErrBadCoefficients)
|
||||
}
|
||||
|
||||
coeffs, estimated, err := featureCoefficients(features)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks := make([]SubTask, len(features))
|
||||
for i, f := range features {
|
||||
criteria := make([]string, 0, len(f.AcceptanceCriteria))
|
||||
for _, c := range f.AcceptanceCriteria {
|
||||
if d := strings.TrimSpace(c.Description); d != "" {
|
||||
criteria = append(criteria, d)
|
||||
}
|
||||
}
|
||||
tasks[i] = SubTask{
|
||||
Title: strings.TrimSpace(f.Title),
|
||||
Description: strings.TrimSpace(f.Description),
|
||||
AcceptanceCriteria: criteria,
|
||||
EffortCoefficient: coeffs[i],
|
||||
}
|
||||
}
|
||||
out := &AtomizeResponse{Tasks: tasks, Model: "anypreta/issue-atomizer"}
|
||||
if !estimated {
|
||||
out.Notes = "The atomizer returned no effort estimation, so the budget was split evenly across the tasks."
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// featureCoefficients turns feature estimations into effort coefficients
|
||||
// summing to exactly 1. Estimations are optional in the spec, so an incomplete
|
||||
// set falls back to an even split (reported via the second return value)
|
||||
// rather than failing the job — bounties still need *some* weight.
|
||||
func featureCoefficients(features []apFeature) ([]float64, bool, error) {
|
||||
raw := make([]float64, len(features))
|
||||
estimated := true
|
||||
for i, f := range features {
|
||||
if f.Estimation == nil || *f.Estimation <= 0 || math.IsNaN(*f.Estimation) {
|
||||
estimated = false
|
||||
break
|
||||
}
|
||||
raw[i] = *f.Estimation
|
||||
}
|
||||
if !estimated {
|
||||
even := 1.0 / float64(len(features))
|
||||
for i := range raw {
|
||||
raw[i] = even
|
||||
}
|
||||
}
|
||||
norm, err := normalizeToOne(raw)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return norm, estimated, nil
|
||||
}
|
||||
|
||||
// normalizeToOne rescales positive weights proportionally so they sum to
|
||||
// exactly 1. Unlike NormalizeCoefficients (§5.1) it accepts any positive sum:
|
||||
// this API never promises a normalized feature set, so a deviation is expected
|
||||
// rather than a service fault.
|
||||
func normalizeToOne(weights []float64) ([]float64, error) {
|
||||
if len(weights) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty", ErrBadCoefficients)
|
||||
}
|
||||
sum := 0.0
|
||||
for _, w := range weights {
|
||||
if math.IsNaN(w) || math.IsInf(w, 0) || w <= 0 {
|
||||
return nil, fmt.Errorf("%w: weight %v is not positive", ErrBadCoefficients, w)
|
||||
}
|
||||
sum += w
|
||||
}
|
||||
out := make([]float64, len(weights))
|
||||
largest, total := 0, 0.0
|
||||
for i, w := range weights {
|
||||
out[i] = math.Round(w/sum*10000) / 10000
|
||||
total += out[i]
|
||||
if out[i] > out[largest] {
|
||||
largest = i
|
||||
}
|
||||
}
|
||||
out[largest] = math.Round((out[largest]+1-total)*10000) / 10000
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// defaultComponents is the component tree we send for every customer.
|
||||
//
|
||||
// The spec marks `components` optional, but the service only emits features for
|
||||
// components it considers *in scope*: an empty tree yields zero features and a
|
||||
// 422 ("no in-scope components produced features"). The board has no component
|
||||
// model of its own, so we send a generic tree covering the usual surfaces of a
|
||||
// software change. Components irrelevant to a ticket simply produce nothing, so
|
||||
// a broad tree costs nothing and a narrow one under-splits — a single catch-all
|
||||
// component collapses every ticket into one feature.
|
||||
var defaultComponents = []apComponent{
|
||||
{ID: "frontend", Description: "User interface: pages, views, client-side behaviour and styling."},
|
||||
{ID: "backend", Description: "Server-side application logic, APIs and background jobs."},
|
||||
{ID: "data", Description: "Data storage: schema, migrations, queries and data integrity."},
|
||||
{ID: "integrations", Description: "Integrations with external systems, third-party APIs and notifications."},
|
||||
}
|
||||
|
||||
// systemFor derives the required system object from the customer, which is the
|
||||
// only project-level context the board has.
|
||||
func systemFor(req AtomizeRequest) apSystem {
|
||||
id := strings.TrimSpace(req.CustomerID)
|
||||
if id == "" {
|
||||
id = "bountyboard"
|
||||
}
|
||||
name := strings.TrimSpace(req.CustomerName)
|
||||
if name == "" {
|
||||
name = "this project"
|
||||
}
|
||||
return apSystem{
|
||||
ID: truncate(id, maxSystemIDLen),
|
||||
Description: truncate(fmt.Sprintf("Software project %q. Work is delivered as small, independently deliverable tasks picked up by freelance developers.", name), maxSystemDesc),
|
||||
Components: defaultComponents,
|
||||
}
|
||||
}
|
||||
|
||||
// issueText flattens the ticket into the single `issue` string the API takes —
|
||||
// it has no fields for acceptance criteria, links or attachments.
|
||||
func issueText(req AtomizeRequest) string {
|
||||
var b strings.Builder
|
||||
if t := strings.TrimSpace(req.Title); t != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", t)
|
||||
}
|
||||
if d := strings.TrimSpace(req.Description); d != "" {
|
||||
fmt.Fprintf(&b, "%s\n", d)
|
||||
}
|
||||
if len(req.AcceptanceCriteria) > 0 {
|
||||
b.WriteString("\nAcceptance criteria:\n")
|
||||
for _, c := range req.AcceptanceCriteria {
|
||||
fmt.Fprintf(&b, "- %s\n", c)
|
||||
}
|
||||
}
|
||||
if len(req.Links) > 0 {
|
||||
b.WriteString("\nLinks:\n")
|
||||
for _, l := range req.Links {
|
||||
fmt.Fprintf(&b, "- %s\n", l)
|
||||
}
|
||||
}
|
||||
if len(req.Attachments) > 0 {
|
||||
b.WriteString("\nAttachments:\n")
|
||||
for _, a := range req.Attachments {
|
||||
fmt.Fprintf(&b, "- %s (%s): %s\n", a.Name, a.MimeType, a.URL)
|
||||
}
|
||||
}
|
||||
s := strings.TrimSpace(b.String())
|
||||
if s == "" {
|
||||
s = "(no description provided)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// guidanceNote carries the consultant's note plus our task-count constraints,
|
||||
// which the API has no dedicated field for.
|
||||
func guidanceNote(req AtomizeRequest) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if n := strings.TrimSpace(req.SubdivisionNote); n != "" {
|
||||
parts = append(parts, n)
|
||||
}
|
||||
if c := req.Constraints; c != nil {
|
||||
switch {
|
||||
case c.MinTasks > 0 && c.MaxTasks > 0:
|
||||
parts = append(parts, fmt.Sprintf("Produce between %d and %d features.", c.MinTasks, c.MaxTasks))
|
||||
case c.MinTasks > 0:
|
||||
parts = append(parts, fmt.Sprintf("Produce at least %d features.", c.MinTasks))
|
||||
case c.MaxTasks > 0:
|
||||
parts = append(parts, fmt.Sprintf("Produce at most %d features.", c.MaxTasks))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// truncate caps a string at max bytes without splitting a rune. Byte length is
|
||||
// a safe proxy for the spec's character limits: it is never smaller.
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut]
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package atomize
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ptr(f float64) *float64 { return &f }
|
||||
|
||||
func sum(xs []float64) float64 {
|
||||
t := 0.0
|
||||
for _, x := range xs {
|
||||
t += x
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func TestFeatureCoefficientsUsesEstimations(t *testing.T) {
|
||||
// The shape the live API returns: estimations already summing to 1.
|
||||
got, estimated, err := featureCoefficients([]apFeature{
|
||||
{Estimation: ptr(0.3636363636)},
|
||||
{Estimation: ptr(0.6363636364)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !estimated {
|
||||
t.Fatal("expected estimations to be used")
|
||||
}
|
||||
if math.Abs(sum(got)-1) > 1e-9 {
|
||||
t.Fatalf("coefficients must sum to exactly 1, got %v (sum %v)", got, sum(got))
|
||||
}
|
||||
if math.Abs(got[0]-0.3636) > 1e-9 {
|
||||
t.Fatalf("estimation not preserved: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureCoefficientsRescalesUnnormalized(t *testing.T) {
|
||||
// Nothing in the spec promises a normalized feature set.
|
||||
got, estimated, err := featureCoefficients([]apFeature{
|
||||
{Estimation: ptr(0.2)}, {Estimation: ptr(0.2)}, {Estimation: ptr(0.4)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !estimated {
|
||||
t.Fatal("expected estimations to be used")
|
||||
}
|
||||
if math.Abs(sum(got)-1) > 1e-9 {
|
||||
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
|
||||
}
|
||||
if math.Abs(got[2]-0.5) > 1e-9 {
|
||||
t.Fatalf("want proportional rescale (0.25/0.25/0.5), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureCoefficientsFallsBackToEvenSplit(t *testing.T) {
|
||||
// estimation is optional and nullable — a missing one must not fail the
|
||||
// job, and must be reported so the consultant knows the split is not real.
|
||||
got, estimated, err := featureCoefficients([]apFeature{
|
||||
{Estimation: ptr(0.5)}, {Estimation: nil}, {Estimation: ptr(0.2)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if estimated {
|
||||
t.Fatal("expected fallback to even split when an estimation is missing")
|
||||
}
|
||||
if math.Abs(sum(got)-1) > 1e-9 {
|
||||
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
|
||||
}
|
||||
for _, c := range got {
|
||||
if math.Abs(c-1.0/3.0) > 1e-3 {
|
||||
t.Fatalf("want even split, got %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeToOneRejectsNonPositive(t *testing.T) {
|
||||
for _, bad := range [][]float64{{0.5, 0}, {0.5, -0.1}, {math.NaN()}, {}} {
|
||||
if _, err := normalizeToOne(bad); err == nil {
|
||||
t.Fatalf("want error for %v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTextCarriesFieldsTheAPILacks(t *testing.T) {
|
||||
// The API takes a single `issue` string: criteria, links and attachments
|
||||
// have no fields of their own and must survive in the text.
|
||||
got := issueText(AtomizeRequest{
|
||||
Title: "Add CSV export",
|
||||
Description: "Customers cannot export.",
|
||||
AcceptanceCriteria: []string{"Downloads a .csv"},
|
||||
Links: []string{"https://tracker/1"},
|
||||
Attachments: []Attachment{{Name: "mock.png", MimeType: "image/png", URL: "https://app/f/1"}},
|
||||
})
|
||||
for _, want := range []string{"Add CSV export", "Customers cannot export.", "Downloads a .csv", "https://tracker/1", "mock.png", "https://app/f/1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("issue text lost %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuidanceNoteFoldsInConstraints(t *testing.T) {
|
||||
got := guidanceNote(AtomizeRequest{
|
||||
SubdivisionNote: "Keep it small.",
|
||||
Constraints: &Constraints{MinTasks: 2, MaxTasks: 5},
|
||||
})
|
||||
if !strings.Contains(got, "Keep it small.") || !strings.Contains(got, "between 2 and 5") {
|
||||
t.Fatalf("want note + constraints, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateDoesNotSplitRunes(t *testing.T) {
|
||||
if got := truncate("héllo", 2); got != "h" {
|
||||
t.Fatalf("want %q, got %q", "h", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Exercises the full remote path against a stub speaking the Anypreta wire
|
||||
// format, asserting both the request we send and the children we build.
|
||||
func TestRemoteAtomizeMapsFeaturesToChildren(t *testing.T) {
|
||||
var got apAtomizeIssueRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/atomize-issue" {
|
||||
t.Errorf("wrong path: %s", r.URL.Path)
|
||||
}
|
||||
if auth := r.Header.Get("Authorization"); auth != "Bearer k" {
|
||||
t.Errorf("wrong auth header: %q", auth)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"feature_set":{"features":[
|
||||
{"id":"f1","title":"UI","stakeholder_summary":"s","description":"d1",
|
||||
"acceptance_criteria":[{"description":"c1"},{"description":"c2"}],
|
||||
"priority":1,"related_components":["web"],"estimation":0.25},
|
||||
{"id":"f2","title":"API","stakeholder_summary":"s","description":"d2",
|
||||
"acceptance_criteria":[{"description":"c3"}],
|
||||
"priority":2,"related_components":["api"],"estimation":0.75}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := newRemote(srv.URL, "k", 5*time.Second).Atomize(t.Context(), AtomizeRequest{
|
||||
TaskID: "t1", Title: "Export", Description: "desc",
|
||||
CustomerID: "cust1", CustomerName: "Acme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Atomize: %v", err)
|
||||
}
|
||||
if got.System.ID != "cust1" || !strings.Contains(got.System.Description, "Acme") {
|
||||
t.Errorf("system not derived from customer: %+v", got.System)
|
||||
}
|
||||
// An empty component tree makes the live service return zero features
|
||||
// (422), so never let the tree go missing.
|
||||
if len(got.System.Components) == 0 {
|
||||
t.Error("system.components must not be empty: the service only emits features for in-scope components")
|
||||
}
|
||||
if len(resp.Tasks) != 2 {
|
||||
t.Fatalf("want 2 children, got %d", len(resp.Tasks))
|
||||
}
|
||||
if resp.Tasks[0].Title != "UI" || resp.Tasks[0].Description != "d1" {
|
||||
t.Errorf("bad child mapping: %+v", resp.Tasks[0])
|
||||
}
|
||||
if len(resp.Tasks[0].AcceptanceCriteria) != 2 || resp.Tasks[0].AcceptanceCriteria[0] != "c1" {
|
||||
t.Errorf("acceptance criteria not mapped: %+v", resp.Tasks[0].AcceptanceCriteria)
|
||||
}
|
||||
if resp.Tasks[0].EffortCoefficient != 0.25 || resp.Tasks[1].EffortCoefficient != 0.75 {
|
||||
t.Errorf("estimation not mapped to effort coefficient: %v / %v",
|
||||
resp.Tasks[0].EffortCoefficient, resp.Tasks[1].EffortCoefficient)
|
||||
}
|
||||
if resp.Notes != "" {
|
||||
t.Errorf("no even-split note expected when estimations are present: %q", resp.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
// A remote-backed client must still serve Extend and Summarize from the §5.1
|
||||
// service — the Anypreta API has no counterpart for either.
|
||||
func TestRemoteDoesNotHijackExtend(t *testing.T) {
|
||||
var hit string
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"task":{"title":"t","description":"d","acceptanceCriteria":[],"effortCoefficient":0.5}}`))
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
c := New(func() string { return mock.URL }, "tok", 5*time.Second)
|
||||
c.UseRemote("http://remote.invalid", "k", 5*time.Second)
|
||||
|
||||
if _, err := c.Extend(t.Context(), ExtendRequest{TaskID: "t1", ExtensionNote: "more"}); err != nil {
|
||||
t.Fatalf("Extend: %v", err)
|
||||
}
|
||||
if hit != "/v1/extend" {
|
||||
t.Fatalf("Extend must go to the §5.1 service, hit %q", hit)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ type AtomizeRequest struct {
|
||||
Links []string `json:"links"`
|
||||
SubdivisionNote string `json:"subdivisionNote,omitempty"`
|
||||
Constraints *Constraints `json:"constraints,omitempty"`
|
||||
// CustomerID / CustomerName describe the project the ticket belongs to.
|
||||
// The §5.1 service ignores them; the Anypreta backend requires them to
|
||||
// build its mandatory `system` object.
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
CustomerName string `json:"customerName,omitempty"`
|
||||
}
|
||||
|
||||
type ExtendRequest struct {
|
||||
@@ -70,6 +75,10 @@ var ErrBadCoefficients = errors.New("atomizer returned invalid effort coefficien
|
||||
|
||||
type Client struct {
|
||||
c *extsvc.Client
|
||||
// remote, when set, serves Atomize from the Anypreta Issue Atomizer
|
||||
// instead of the §5.1 service. Extend and Summarize have no counterpart
|
||||
// there and always go to c. See anypreta.go.
|
||||
remote *remote
|
||||
}
|
||||
|
||||
// New builds the client. baseURL is resolved per call so the admin settings
|
||||
@@ -78,13 +87,46 @@ func New(baseURL func() string, token string, timeout time.Duration) *Client {
|
||||
return &Client{c: extsvc.NewClient("atomizer", baseURL, token, timeout)}
|
||||
}
|
||||
|
||||
func (a *Client) BreakerState() string { return a.c.BreakerState() }
|
||||
func (a *Client) Healthy(ctx context.Context) error { return a.c.Healthy(ctx) }
|
||||
// UseRemote routes Atomize to the Anypreta Issue Atomizer at baseURL.
|
||||
func (a *Client) UseRemote(baseURL, apiKey string, timeout time.Duration) {
|
||||
a.remote = newRemote(baseURL, apiKey, timeout)
|
||||
}
|
||||
|
||||
// RemoteEnabled reports whether Subdivide is served by the Anypreta backend.
|
||||
func (a *Client) RemoteEnabled() bool { return a.remote != nil }
|
||||
|
||||
func (a *Client) BreakerState() string {
|
||||
if a.remote != nil {
|
||||
if s := a.remote.c.BreakerState(); s != "closed" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return a.c.BreakerState()
|
||||
}
|
||||
|
||||
// Healthy probes every backend an atomization can touch, so readiness and the
|
||||
// admin service panel go degraded if either half is down.
|
||||
func (a *Client) Healthy(ctx context.Context) error {
|
||||
if err := a.c.Healthy(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.remote != nil {
|
||||
if err := a.remote.c.Healthy(ctx); err != nil {
|
||||
return fmt.Errorf("anypreta atomizer: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Atomize calls POST /v1/atomize and applies the §5.1 contract: coefficients
|
||||
// must sum to 1 ± 0.001; a deviation ≤ 0.05 is defensively renormalized,
|
||||
// anything worse is rejected as a service error.
|
||||
// anything worse is rejected as a service error. With a remote backend
|
||||
// configured the call is served by the Anypreta API instead, which normalizes
|
||||
// its own feature estimations.
|
||||
func (a *Client) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
|
||||
if a.remote != nil {
|
||||
return a.remote.Atomize(ctx, req)
|
||||
}
|
||||
var resp AtomizeResponse
|
||||
if err := a.c.Call(ctx, http.MethodPost, "/v1/atomize", req, &resp); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -99,6 +99,15 @@ func (s *Service) HandleSubdivide(ctx context.Context, job *jobs.Job) error {
|
||||
Attachments: s.requestAttachments(t),
|
||||
Links: taskLinks(t),
|
||||
SubdivisionNote: p.Note,
|
||||
CustomerID: t.CustomerID,
|
||||
}
|
||||
// Project context for backends that need it (the Anypreta `system`
|
||||
// object). Not worth failing an atomization over.
|
||||
if cust, err := s.st.CustomerByID(ctx, t.CustomerID); err == nil {
|
||||
req.CustomerName = cust.Name
|
||||
} else {
|
||||
s.log.Warn("subdivide: customer lookup failed, sending id only",
|
||||
"task", t.ID, "customer", t.CustomerID, "err", err)
|
||||
}
|
||||
if p.MinTasks > 0 || p.MaxTasks > 0 {
|
||||
req.Constraints = &Constraints{MinTasks: p.MinTasks, MaxTasks: p.MaxTasks}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -31,10 +31,10 @@ func SanitizeHTML(in string) string {
|
||||
if c != '<' {
|
||||
j := strings.IndexByte(in[i:], '<')
|
||||
if j < 0 {
|
||||
out.WriteString(html.EscapeString(in[i:]))
|
||||
out.WriteString(escapeText(in[i:]))
|
||||
break
|
||||
}
|
||||
out.WriteString(html.EscapeString(in[i : i+j]))
|
||||
out.WriteString(escapeText(in[i : i+j]))
|
||||
i += j
|
||||
continue
|
||||
}
|
||||
@@ -53,6 +53,31 @@ func SanitizeHTML(in string) string {
|
||||
name, attrs := splitTag(raw)
|
||||
name = strings.ToLower(name)
|
||||
|
||||
// @-mention tokens: <span class="mention" data-user-card="ULID">@Name</span>.
|
||||
// Only this exact shape is allowed; any other span is dropped.
|
||||
if name == "span" {
|
||||
if closing {
|
||||
for n := len(stack) - 1; n >= 0; n-- {
|
||||
if stack[n] == "span" {
|
||||
for len(stack) > n {
|
||||
top := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
out.WriteString("</" + top + ">")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
uid := mentionUID(attrs)
|
||||
if uid == "" {
|
||||
continue // drop the opening span, keep its text
|
||||
}
|
||||
out.WriteString(`<span class="mention" data-user-card="` + html.EscapeString(uid) + `">`)
|
||||
stack = append(stack, "span")
|
||||
continue
|
||||
}
|
||||
|
||||
if !allowedTags[name] {
|
||||
continue // drop the tag entirely, keep surrounding text
|
||||
}
|
||||
@@ -93,6 +118,15 @@ func SanitizeHTML(in string) string {
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// escapeText normalizes a text run from (possibly entity-encoded) input HTML:
|
||||
// decode existing entities, then re-escape. This keeps a single round of
|
||||
// escaping so entities the browser emits — e.g. for spaces around inline
|
||||
// elements, or & for a typed "&" — don't get double-escaped into visible
|
||||
// " "/"&" text. Re-escaping keeps the output XSS-safe.
|
||||
func escapeText(s string) string {
|
||||
return html.EscapeString(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
// splitTag separates the tag name from its raw attribute string.
|
||||
func splitTag(raw string) (string, string) {
|
||||
for i := 0; i < len(raw); i++ {
|
||||
@@ -104,6 +138,50 @@ func splitTag(raw string) (string, string) {
|
||||
return raw, ""
|
||||
}
|
||||
|
||||
// mentionUID validates a mention span's attributes and returns the referenced
|
||||
// user id (alphanumeric, ULID-shaped), or "" if the span is not a valid
|
||||
// mention.
|
||||
func mentionUID(attrs string) string {
|
||||
low := strings.ToLower(attrs)
|
||||
if !strings.Contains(low, "mention") {
|
||||
return ""
|
||||
}
|
||||
idx := strings.Index(low, "data-user-card")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(attrs[idx+len("data-user-card"):])
|
||||
if !strings.HasPrefix(rest, "=") {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimSpace(rest[1:])
|
||||
var val string
|
||||
switch {
|
||||
case strings.HasPrefix(rest, `"`):
|
||||
if e := strings.IndexByte(rest[1:], '"'); e >= 0 {
|
||||
val = rest[1 : 1+e]
|
||||
}
|
||||
case strings.HasPrefix(rest, "'"):
|
||||
if e := strings.IndexByte(rest[1:], '\''); e >= 0 {
|
||||
val = rest[1 : 1+e]
|
||||
}
|
||||
default:
|
||||
val = rest
|
||||
if sp := strings.IndexAny(val, " \t"); sp >= 0 {
|
||||
val = val[:sp]
|
||||
}
|
||||
}
|
||||
if val == "" || len(val) > 32 {
|
||||
return ""
|
||||
}
|
||||
for _, r := range val {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// safeHref extracts href and accepts only http, https, or mailto schemes.
|
||||
func safeHref(attrs string) string {
|
||||
lower := strings.ToLower(attrs)
|
||||
@@ -150,10 +228,10 @@ func SanitizePlain(in string) string {
|
||||
if in[i] != '<' {
|
||||
j := strings.IndexByte(in[i:], '<')
|
||||
if j < 0 {
|
||||
out.WriteString(html.EscapeString(in[i:]))
|
||||
out.WriteString(escapeText(in[i:]))
|
||||
break
|
||||
}
|
||||
out.WriteString(html.EscapeString(in[i : i+j]))
|
||||
out.WriteString(escapeText(in[i : i+j]))
|
||||
i += j
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ func TestSanitizeHTML(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"plain text escaped", `hello <world> & "friends"`, "hello & "friends""},
|
||||
{"existing entity not double-escaped", `a & b <ok>`, "a & b <ok>"},
|
||||
{"mention span kept with sanitized uid", `<span class="mention" data-user-card="01ABCdef">@Jane Doe</span> hi`,
|
||||
`<span class="mention" data-user-card="01ABCdef">@Jane Doe</span> hi`},
|
||||
{"non-mention span dropped", `<span style="x">keep text</span>`, "keep text"},
|
||||
{"allowed formatting kept", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>"},
|
||||
{"paragraph and lists", "<p>a</p><ul><li>x</li><li>y</li></ul>", "<p>a</p><ul><li>x</li><li>y</li></ul>"},
|
||||
{"code and pre", "<pre><code>x := 1</code></pre>", "<pre><code>x := 1</code></pre>"},
|
||||
|
||||
@@ -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
|
||||
@@ -53,6 +57,11 @@ type Config struct {
|
||||
AtomizerBaseURL string
|
||||
AtomizerToken string
|
||||
AtomizerTimeout time.Duration
|
||||
// AtomizerRemoteBaseURL / AtomizerRemoteAPIKey enable the Anypreta Issue
|
||||
// Atomizer as the Subdivide backend. Both must be set; Extend and
|
||||
// Summarize stay on ATOMIZER_BASE_URL either way.
|
||||
AtomizerRemoteBaseURL string
|
||||
AtomizerRemoteAPIKey string
|
||||
WorkPerformerBaseURL string
|
||||
WorkPerformerToken string
|
||||
WorkPerformerHTTPTimeout time.Duration
|
||||
@@ -113,6 +122,8 @@ func Load() (*Config, error) {
|
||||
|
||||
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
|
||||
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
|
||||
AtomizerRemoteBaseURL: strings.TrimRight(os.Getenv("ATOMIZER_REMOTE_BASE_URL"), "/"),
|
||||
AtomizerRemoteAPIKey: os.Getenv("ATOMIZER_REMOTE_API_KEY"),
|
||||
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
|
||||
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
|
||||
|
||||
@@ -132,6 +143,7 @@ func Load() (*Config, error) {
|
||||
CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
|
||||
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"),
|
||||
}
|
||||
c.AppInternalURL = strings.TrimRight(getenv("APP_INTERNAL_URL", c.AppBaseURL), "/")
|
||||
c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
|
||||
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
|
||||
|
||||
@@ -150,6 +162,14 @@ func Load() (*Config, error) {
|
||||
fail("%s: %q is not an absolute URL", name, v)
|
||||
}
|
||||
}
|
||||
if c.AtomizerRemoteBaseURL != "" {
|
||||
if u, err := url.Parse(c.AtomizerRemoteBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
fail("ATOMIZER_REMOTE_BASE_URL: %q is not an absolute URL", c.AtomizerRemoteBaseURL)
|
||||
}
|
||||
if c.AtomizerRemoteAPIKey == "" {
|
||||
fail("ATOMIZER_REMOTE_API_KEY: required when ATOMIZER_REMOTE_BASE_URL is set")
|
||||
}
|
||||
}
|
||||
if len(c.SessionSecret) < 16 {
|
||||
fail("SESSION_SECRET: must be at least 16 characters of random data")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const (
|
||||
TicketingJira TicketingType = "jira"
|
||||
TicketingAzure TicketingType = "azure_devops"
|
||||
TicketingYouTrack TicketingType = "youtrack"
|
||||
TicketingWekan TicketingType = "wekan"
|
||||
// TicketingDemo fabricates tickets locally so the whole flow works
|
||||
// offline (§11.11).
|
||||
TicketingDemo TicketingType = "demo"
|
||||
@@ -15,7 +16,7 @@ const (
|
||||
|
||||
func (t TicketingType) Valid() bool {
|
||||
switch t {
|
||||
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingDemo:
|
||||
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingWekan, TicketingDemo:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -27,6 +28,11 @@ type Ticketing struct {
|
||||
CredentialsEnc string `bson:"credentialsEnc" json:"-"`
|
||||
ProjectKey string `bson:"projectKey" json:"projectKey"`
|
||||
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
|
||||
// ConsultantIdentities maps a bounty-board consultant id to that
|
||||
// consultant's username in THIS customer's ticketing system. It takes
|
||||
// precedence over the consultant's global extra.ticketingIdentities so an
|
||||
// admin can map accounts per project (§5.3).
|
||||
ConsultantIdentities map[string]string `bson:"consultantIdentities,omitempty" json:"consultantIdentities,omitempty"`
|
||||
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
|
||||
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
|
||||
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
|
||||
|
||||
@@ -54,7 +54,9 @@ type NotificationPrefs struct {
|
||||
|
||||
type UserSettings struct {
|
||||
Theme string `bson:"theme" json:"theme"`
|
||||
NavLayout string `bson:"navLayout" json:"navLayout"` // "side" (default) | "top"
|
||||
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
||||
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
|
||||
@@ -121,13 +121,34 @@ func (c *Client) callOnce(ctx context.Context, method, path string, in, out any)
|
||||
if resp.StatusCode >= 400 {
|
||||
se := &ServiceError{Status: resp.StatusCode}
|
||||
var env struct {
|
||||
// §5 envelope: {"error":{"code":…,"message":…}}.
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
// Flat envelope, used by the Anypreta atomizer:
|
||||
// {"error_code":…,"message":…}. FastAPI's own validation
|
||||
// errors carry {"detail":…} instead.
|
||||
ErrorCode string `json:"error_code"`
|
||||
Message string `json:"message"`
|
||||
Detail json.RawMessage `json:"detail"`
|
||||
}
|
||||
if json.Unmarshal(data, &env) == nil {
|
||||
se.Code, se.Message = env.Error.Code, env.Error.Message
|
||||
if se.Code == "" {
|
||||
se.Code = env.ErrorCode
|
||||
}
|
||||
if se.Message == "" {
|
||||
se.Message = env.Message
|
||||
}
|
||||
if se.Message == "" && len(env.Detail) > 0 {
|
||||
se.Message = string(truncate(env.Detail, 200))
|
||||
}
|
||||
}
|
||||
if se.Message == "" {
|
||||
// Never swallow the failure: an unrecognized envelope still
|
||||
// has to reach the logs and the consultant's error toast.
|
||||
se.Message = string(truncate(data, 200))
|
||||
}
|
||||
return se // 4xx is not retryable
|
||||
}
|
||||
|
||||
@@ -102,14 +102,20 @@ func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit := atoiDefault(q.Get("limit"), 50)
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"),
|
||||
q.Get("cursor"), atoiDefault(q.Get("limit"), 50))
|
||||
q.Get("cursor"), limit)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list audit", err)
|
||||
return
|
||||
}
|
||||
// Only advertise a next cursor when the page is full; otherwise this is the
|
||||
// last page and a further fetch would return nothing (the "click twice" bug).
|
||||
next := ""
|
||||
if len(entries) > 0 {
|
||||
if len(entries) == limit {
|
||||
next = entries[len(entries)-1].ID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next})
|
||||
|
||||
@@ -26,6 +26,7 @@ type customerPayload struct {
|
||||
Credentials *syncpkg.Credentials `json:"credentials"`
|
||||
PollInterval *int `json:"pollIntervalSec"`
|
||||
ConsultantIDs *[]string `json:"consultantIds"`
|
||||
ConsultantIdentities *map[string]string `json:"consultantIdentities"`
|
||||
DefaultBudget *float64 `json:"defaultBudget"`
|
||||
Archived *bool `json:"archived"`
|
||||
Version *int64 `json:"version"`
|
||||
@@ -65,6 +66,7 @@ func customerJSON(c *domain.Customer) map[string]any {
|
||||
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
|
||||
"lastSyncError": c.Ticketing.LastSyncError,
|
||||
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
||||
"consultantIdentities": c.Ticketing.ConsultantIdentities,
|
||||
},
|
||||
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
|
||||
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
||||
@@ -99,6 +101,18 @@ func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)})
|
||||
}
|
||||
|
||||
// cleanIdentities trims values and drops empty mappings so we don't persist
|
||||
// blank usernames for consultants the admin left unmapped.
|
||||
func cleanIdentities(in map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range in {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
out[k] = t
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
|
||||
for _, id := range ids {
|
||||
u, err := s.store.UserByID(ctx, id)
|
||||
@@ -119,7 +133,7 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
if req.Type == nil || !req.Type.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack or demo")
|
||||
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack, wekan or demo")
|
||||
return
|
||||
}
|
||||
settings, err := s.store.GetSettings(r.Context())
|
||||
@@ -156,6 +170,9 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
c.ConsultantIDs = *req.ConsultantIDs
|
||||
}
|
||||
if req.ConsultantIdentities != nil {
|
||||
c.Ticketing.ConsultantIdentities = cleanIdentities(*req.ConsultantIdentities)
|
||||
}
|
||||
creds := syncpkg.Credentials{}
|
||||
if req.Credentials != nil {
|
||||
creds = *req.Credentials
|
||||
@@ -236,6 +253,10 @@ func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request
|
||||
set["consultantIds"] = *req.ConsultantIDs
|
||||
diff["consultantIds"] = *req.ConsultantIDs
|
||||
}
|
||||
if req.ConsultantIdentities != nil {
|
||||
set["ticketing.consultantIdentities"] = cleanIdentities(*req.ConsultantIdentities)
|
||||
diff["consultantIdentities"] = "updated"
|
||||
}
|
||||
if req.DefaultBudget != nil {
|
||||
set["defaultBudget"] = *req.DefaultBudget
|
||||
diff["defaultBudget"] = *req.DefaultBudget
|
||||
|
||||
@@ -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.
|
||||
|
||||
+92
-57
@@ -19,7 +19,9 @@ import (
|
||||
|
||||
func (s *Server) routesBoard(mux *http.ServeMux) {
|
||||
dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) }
|
||||
mux.Handle("GET /api/v1/board", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleBoard))))
|
||||
// Developers and consultants can both read the board (consultants browse to
|
||||
// open task detail; claim/start/etc. below stay developer-only).
|
||||
mux.Handle("GET /api/v1/board", s.requireAuth(http.HandlerFunc(s.handleBoard)))
|
||||
mux.Handle("GET /api/v1/my-tasks", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleMyTasks))))
|
||||
mux.Handle("POST /api/v1/tasks/{id}/claim", dev(s.handleClaim))
|
||||
mux.Handle("POST /api/v1/tasks/{id}/claim/withdraw", dev(s.handleWithdrawClaim))
|
||||
@@ -33,28 +35,69 @@ func (s *Server) routesBoard(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard)))
|
||||
}
|
||||
|
||||
// boardConsultants resolves which consultants' tasks the developer sees:
|
||||
// the consultants who selected them into a pool (§3).
|
||||
func (s *Server) boardConsultants(r *http.Request) ([]string, error) {
|
||||
return s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
|
||||
// boardCustomers resolves the developer's visibility scope: every customer
|
||||
// that has at least one consultant who selected this developer into a pool.
|
||||
// Visibility is customer-based, not task-owner-based — §4.4: all consultants
|
||||
// assigned to a customer manage its tasks, so a task published by any of
|
||||
// them belongs to the same board.
|
||||
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
|
||||
me := CurrentUser(r.Context())
|
||||
seen := map[string]bool{}
|
||||
out := []domain.Customer{}
|
||||
add := func(customers []domain.Customer) {
|
||||
for _, c := range customers {
|
||||
if !seen[c.ID] {
|
||||
seen[c.ID] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Developers: every customer that has a consultant who pooled them.
|
||||
if me.Roles.Developer {
|
||||
consultants, err := s.store.PoolConsultantIDs(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, cid := range consultants {
|
||||
customers, err := s.store.ListCustomers(r.Context(), cid, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
add(customers)
|
||||
}
|
||||
}
|
||||
// Consultants: every customer they are assigned to.
|
||||
if me.Roles.Consultant {
|
||||
customers, err := s.store.ListCustomers(r.Context(), me.ID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
add(customers)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
|
||||
consultants, err := s.boardConsultants(r)
|
||||
customers, err := s.boardCustomers(r)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "pool lookup", err)
|
||||
s.internalError(w, r, "board scope", err)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
customerIDs := []string{}
|
||||
for _, c := range customers {
|
||||
if want := q.Get("customerId"); want == "" || want == c.ID {
|
||||
customerIDs = append(customerIDs, c.ID)
|
||||
}
|
||||
}
|
||||
tasks := []domain.Task{}
|
||||
if len(consultants) > 0 {
|
||||
if len(customerIDs) > 0 {
|
||||
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
|
||||
filter := store.TaskFilter{
|
||||
ConsultantIDs: consultants,
|
||||
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 +121,12 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
|
||||
tasks[i].ClaimRequests = mine
|
||||
}
|
||||
|
||||
// customers for the filter dropdown
|
||||
customerIDs := map[string]bool{}
|
||||
for _, t := range tasks {
|
||||
customerIDs[t.CustomerID] = true
|
||||
// customers for the filter dropdown (full visibility scope)
|
||||
customersOut := make([]map[string]any, len(customers))
|
||||
for i, c := range customers {
|
||||
customersOut[i] = map[string]any{"id": c.ID, "name": c.Name}
|
||||
}
|
||||
customers := []map[string]any{}
|
||||
for id := range customerIDs {
|
||||
if c, err := s.store.CustomerByID(r.Context(), id); err == nil {
|
||||
customers = append(customers, map[string]any{"id": c.ID, "name": c.Name})
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customers})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut})
|
||||
}
|
||||
|
||||
func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -110,8 +147,9 @@ func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
|
||||
}
|
||||
|
||||
// developerTask loads a task ensuring the developer can see it via a pool
|
||||
// relationship with its consultant.
|
||||
// developerTask loads a task ensuring the developer can see it: the task's
|
||||
// customer is in their pool-derived visibility scope, or they are the
|
||||
// assignee (access survives mid-flight pool removal).
|
||||
func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, bool) {
|
||||
t, err := s.store.TaskByID(r.Context(), id)
|
||||
if err != nil {
|
||||
@@ -122,20 +160,19 @@ func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
consultants, err := s.boardConsultants(r)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "pool lookup", err)
|
||||
return nil, false
|
||||
}
|
||||
for _, cid := range consultants {
|
||||
if cid == t.ConsultantID {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
// assignees keep access even if removed from the pool mid-flight
|
||||
if t.IsAssignedTo(CurrentUser(r.Context()).ID) {
|
||||
return t, true
|
||||
}
|
||||
customers, err := s.boardCustomers(r)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "board scope", err)
|
||||
return nil, false
|
||||
}
|
||||
for _, c := range customers {
|
||||
if c.ID == t.CustomerID {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "forbidden", "this task is not on your board")
|
||||
return nil, false
|
||||
}
|
||||
@@ -299,7 +336,9 @@ func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
var mentionRe = regexp.MustCompile(`@([\w.+-]+@[\w.-]+\.\w+|\w+)`)
|
||||
// mentionUIDRe pulls user ids out of mention spans the sanitizer emitted:
|
||||
// <span class="mention" data-user-card="ULID">@Name</span>.
|
||||
var mentionUIDRe = regexp.MustCompile(`data-user-card="([0-9A-Za-z]+)"`)
|
||||
|
||||
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
@@ -355,45 +394,41 @@ func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
|
||||
}
|
||||
|
||||
// notifyMentions resolves @name / @email tokens against task participants
|
||||
// (§11.3).
|
||||
// notifyMentions notifies users referenced by mention spans in the comment
|
||||
// body, but only those who actually have access to the task (§11.3).
|
||||
func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
|
||||
plain := chat.SanitizePlain(body)
|
||||
matches := mentionRe.FindAllStringSubmatch(plain, 10)
|
||||
matches := mentionUIDRe.FindAllStringSubmatch(body, 20)
|
||||
if len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
// candidate participants
|
||||
ids := map[string]bool{t.ConsultantID: true}
|
||||
ctx := r.Context()
|
||||
// who can see this task: its consultants, assignee, claimers, commenters
|
||||
access := map[string]bool{t.ConsultantID: true}
|
||||
if c, err := s.store.CustomerByID(ctx, t.CustomerID); err == nil {
|
||||
for _, id := range c.ConsultantIDs {
|
||||
access[id] = true
|
||||
}
|
||||
}
|
||||
if t.Assignee != nil && t.Assignee.UserID != "" {
|
||||
ids[t.Assignee.UserID] = true
|
||||
access[t.Assignee.UserID] = true
|
||||
}
|
||||
for _, cr := range t.ClaimRequests {
|
||||
ids[cr.DeveloperID] = true
|
||||
access[cr.DeveloperID] = true
|
||||
}
|
||||
for _, c := range t.Comments {
|
||||
ids[c.AuthorID] = true
|
||||
access[c.AuthorID] = true
|
||||
}
|
||||
notified := map[string]bool{}
|
||||
for _, m := range matches {
|
||||
token := strings.ToLower(m[1])
|
||||
for id := range ids {
|
||||
if id == author.ID || notified[id] {
|
||||
uid := m[1]
|
||||
if uid == author.ID || notified[uid] || !access[uid] {
|
||||
continue
|
||||
}
|
||||
u, err := s.store.UserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
|
||||
if token == strings.ToLower(u.Email) || token == first {
|
||||
notified[id] = true
|
||||
s.notifyUser(r.Context(), id, "mention",
|
||||
notified[uid] = true
|
||||
s.notifyUser(ctx, uid, "mention",
|
||||
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLogTime(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"bountyboard/internal/chat"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/files"
|
||||
"bountyboard/internal/store"
|
||||
"bountyboard/internal/ws"
|
||||
)
|
||||
|
||||
func (s *Server) routesMessages(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/conversations", s.requireAuth(http.HandlerFunc(s.handleListConversations)))
|
||||
mux.Handle("POST /api/v1/conversations", s.authed(s.handleCreateConversation))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages)))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/members", s.requireAuth(http.HandlerFunc(s.handleListMembers)))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/members", s.authed(s.handleAddMember))
|
||||
mux.Handle("DELETE /api/v1/conversations/{id}/members/{userId}", s.authed(s.handleRemoveMember))
|
||||
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
||||
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
||||
mux.Handle("GET /api/v1/messages/search", s.requireAuth(http.HandlerFunc(s.handleSearchMessages)))
|
||||
}
|
||||
|
||||
// conversation loads + authorizes participant access.
|
||||
func (s *Server) conversation(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
|
||||
conv, err := s.store.ConversationByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "conversation not found")
|
||||
} else {
|
||||
s.internalError(w, r, "load conversation", err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if !conv.HasParticipant(CurrentUser(r.Context()).ID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "you are not in this conversation")
|
||||
return nil, false
|
||||
}
|
||||
return conv, true
|
||||
}
|
||||
|
||||
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
convs, err := s.store.ListConversations(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list conversations", err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, len(convs))
|
||||
for i, c := range convs {
|
||||
ids[i] = c.ID
|
||||
}
|
||||
unread, err := s.store.UnreadCounts(r.Context(), me.ID, ids)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "unread counts", err)
|
||||
return
|
||||
}
|
||||
// resolve display names for the list
|
||||
out := make([]map[string]any, len(convs))
|
||||
for i, c := range convs {
|
||||
title := c.Title
|
||||
if c.Kind == "dm" {
|
||||
for _, pid := range c.ParticipantIDs {
|
||||
if pid != me.ID {
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
title = u.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = "Conversation"
|
||||
}
|
||||
out[i] = map[string]any{
|
||||
"id": c.ID, "kind": c.Kind, "title": title,
|
||||
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
|
||||
"creatorId": c.CreatorID,
|
||||
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"conversations": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
CustomerID string `json:"customerId"`
|
||||
ParticipantIDs []string `json:"participantIds"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
|
||||
// validate participants exist
|
||||
seen := map[string]bool{me.ID: true}
|
||||
participants := []string{me.ID}
|
||||
for _, id := range req.ParticipantIDs {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.UserByID(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_participant", "unknown user "+id)
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
participants = append(participants, id)
|
||||
}
|
||||
|
||||
switch req.Kind {
|
||||
case "dm":
|
||||
if len(participants) != 2 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "dm needs exactly one other participant")
|
||||
return
|
||||
}
|
||||
conv, err := s.store.FindOrCreateDM(r.Context(), participants[0], participants[1])
|
||||
if err != nil {
|
||||
s.internalError(w, r, "create dm", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"conversation": conv})
|
||||
return
|
||||
case "group", "project":
|
||||
if len(participants) < 2 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "need at least one other participant")
|
||||
return
|
||||
}
|
||||
conv := &store.Conversation{
|
||||
Kind: req.Kind,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
CreatorID: me.ID,
|
||||
ParticipantIDs: participants,
|
||||
}
|
||||
if req.Kind == "project" {
|
||||
if req.CustomerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "project conversations need customerId")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CustomerByID(r.Context(), req.CustomerID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_customer", "unknown customer")
|
||||
return
|
||||
}
|
||||
conv.CustomerID = req.CustomerID
|
||||
}
|
||||
if conv.Title == "" && req.Kind == "group" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "group conversations need a title")
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateConversation(r.Context(), conv); err != nil {
|
||||
s.internalError(w, r, "create conversation", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conv})
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_kind", "kind must be dm, group or project")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
msgs, err := s.store.ListMessages(r.Context(), conv.ID, q.Get("cursor"),
|
||||
atoiDefault(q.Get("limit"), 50))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list messages", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
|
||||
}
|
||||
|
||||
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Body string `json:"body"`
|
||||
Attachments []string `json:"attachments"` // fileIds from POST /api/v1/files
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
body := chat.SanitizeHTML(req.Body)
|
||||
if strings.TrimSpace(chat.SanitizePlain(body)) == "" && len(req.Attachments) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty_message", "message needs text or attachments")
|
||||
return
|
||||
}
|
||||
|
||||
atts := []store.MessageAttachment{}
|
||||
for _, fid := range req.Attachments {
|
||||
rc, meta, err := s.files.Open(r.Context(), fid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_attachment", "unknown file "+fid)
|
||||
return
|
||||
}
|
||||
rc.Close()
|
||||
if meta.OwnerID != me.ID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "attachment was uploaded by someone else")
|
||||
return
|
||||
}
|
||||
atts = append(atts, store.MessageAttachment{
|
||||
FileID: meta.ID, Name: meta.Name, MimeType: meta.MimeType,
|
||||
Size: meta.Size, IsImage: strings.HasPrefix(meta.MimeType, "image/"),
|
||||
})
|
||||
}
|
||||
|
||||
msg := &store.Message{ConversationID: conv.ID, SenderID: me.ID, Body: body, Attachments: atts}
|
||||
if err := s.store.InsertMessage(r.Context(), msg); err != nil {
|
||||
s.internalError(w, r, "insert message", err)
|
||||
return
|
||||
}
|
||||
s.metrics.Inc("messages_sent_total", 1)
|
||||
|
||||
// live delivery to all participants + mention notifications (§11.1)
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
|
||||
}
|
||||
// Notify mentioned users who are participants (i.e. have access §11.1).
|
||||
plain := chat.SanitizePlain(body)
|
||||
participants := map[string]bool{}
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
participants[pid] = true
|
||||
}
|
||||
notified := map[string]bool{}
|
||||
for _, m := range mentionUIDRe.FindAllStringSubmatch(body, 20) {
|
||||
uid := m[1]
|
||||
if uid == me.ID || notified[uid] || !participants[uid] {
|
||||
continue
|
||||
}
|
||||
notified[uid] = true
|
||||
s.notifyUser(r.Context(), uid, "mention",
|
||||
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := s.store.MarkConversationRead(r.Context(), conv.ID, CurrentUser(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "mark read", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
|
||||
}
|
||||
|
||||
// groupOwner loads a group/project conversation and authorizes the creator.
|
||||
func (s *Server) groupOwner(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
|
||||
conv, ok := s.conversation(w, r, id)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if conv.Kind == "dm" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "direct messages have no managed members")
|
||||
return nil, false
|
||||
}
|
||||
if conv.CreatorID != CurrentUser(r.Context()).ID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "only the group creator can manage members")
|
||||
return nil, false
|
||||
}
|
||||
return conv, true
|
||||
}
|
||||
|
||||
// handleListMembers returns the participants; any member may view.
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
out := make([]map[string]any, 0, len(conv.ParticipantIDs))
|
||||
for _, id := range conv.ParticipantIDs {
|
||||
u, err := s.store.UserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": u.ID, "name": u.Name, "email": u.Email,
|
||||
"avatarFileId": u.AvatarFileID, "isCreator": id == conv.CreatorID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"members": out,
|
||||
"creatorId": conv.CreatorID,
|
||||
"canManage": conv.Kind != "dm" && conv.CreatorID == me.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAddMember adds a participant to a group (creator only).
|
||||
func (s *Server) handleAddMember(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.UserID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "userId is required")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UserByID(r.Context(), req.UserID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_user", "unknown user")
|
||||
return
|
||||
}
|
||||
if err := s.store.AddParticipant(r.Context(), conv.ID, req.UserID); err != nil {
|
||||
s.internalError(w, r, "add member", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleRemoveMember removes a participant from a group (creator only).
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
target := r.PathValue("userId")
|
||||
if target == conv.CreatorID {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "the creator cannot be removed")
|
||||
return
|
||||
}
|
||||
if err := s.store.RemoveParticipant(r.Context(), conv.ID, target); err != nil {
|
||||
s.internalError(w, r, "remove member", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
|
||||
// chat; scope=chat unless the caller passes scope=task (consultants).
|
||||
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
if !s.loginLimiter.Allow("upload|" + ClientIP(r.Context()).String()) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many uploads, slow down")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(s.cfg.MaxUploadMB)<<20+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "multipart field 'file' is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
scope := files.ScopeChat
|
||||
if r.FormValue("scope") == files.ScopeTask && (me.Roles.Consultant || me.Roles.Admin) {
|
||||
scope = files.ScopeTask
|
||||
}
|
||||
meta, err := s.files.Save(r.Context(), scope, me.ID, header.Filename,
|
||||
header.Header.Get("Content-Type"), file)
|
||||
if err != nil {
|
||||
if errors.Is(err, files.ErrTooLarge) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", err.Error())
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "save upload", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"fileId": meta.ID, "name": meta.Name, "mimeType": meta.MimeType,
|
||||
"size": meta.Size, "isImage": strings.HasPrefix(meta.MimeType, "image/"),
|
||||
})
|
||||
}
|
||||
|
||||
// handleSearchUsers is the small directory for picking conversation
|
||||
// participants (§11.10).
|
||||
func (s *Server) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
filter := bson.M{"disabled": false}
|
||||
if q != "" {
|
||||
filter["$or"] = []bson.M{
|
||||
{"email": bson.M{"$regex": regexEscape(q), "$options": "i"}},
|
||||
{"name": bson.M{"$regex": regexEscape(q), "$options": "i"}},
|
||||
}
|
||||
}
|
||||
cur, err := s.store.DB.Collection("users").Find(r.Context(), filter,
|
||||
options.Find().SetLimit(20).SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "search users", err)
|
||||
return
|
||||
}
|
||||
var users []domain.User
|
||||
if err := cur.All(r.Context(), &users); err != nil {
|
||||
s.internalError(w, r, "decode users", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, map[string]any{
|
||||
"id": u.ID, "name": u.Name, "email": u.Email, "avatarFileId": u.AvatarFileID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||
}
|
||||
|
||||
// handleSearchMessages finds messages containing the query within the caller's
|
||||
// own conversations, newest first.
|
||||
func (s *Server) handleSearchMessages(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if len([]rune(q)) < 2 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
||||
return
|
||||
}
|
||||
convs, err := s.store.ListConversations(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "search: conversations", err)
|
||||
return
|
||||
}
|
||||
if len(convs) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(convs))
|
||||
titles := make(map[string]string, len(convs))
|
||||
for _, c := range convs {
|
||||
ids = append(ids, c.ID)
|
||||
title := c.Title
|
||||
if c.Kind == "dm" {
|
||||
for _, pid := range c.ParticipantIDs {
|
||||
if pid != me.ID {
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
title = u.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = "Conversation"
|
||||
}
|
||||
titles[c.ID] = title
|
||||
}
|
||||
cur, err := s.store.DB.Collection("messages").Find(r.Context(), bson.M{
|
||||
"conversationId": bson.M{"$in": ids},
|
||||
"deletedAt": nil,
|
||||
"body": bson.M{"$regex": regexEscape(q), "$options": "i"},
|
||||
}, options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(30))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "search messages", err)
|
||||
return
|
||||
}
|
||||
var msgs []store.Message
|
||||
if err := cur.All(r.Context(), &msgs); err != nil {
|
||||
s.internalError(w, r, "decode search", err)
|
||||
return
|
||||
}
|
||||
results := make([]map[string]any, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
results = append(results, map[string]any{
|
||||
"conversationId": m.ConversationID,
|
||||
"conversationTitle": titles[m.ConversationID],
|
||||
"messageId": m.ID,
|
||||
"senderId": m.SenderID,
|
||||
"snippet": snippetAround(stripTags(m.Body), q),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
// stripTags removes HTML tags and collapses whitespace for plain-text snippets.
|
||||
func stripTags(html string) string {
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range html {
|
||||
switch {
|
||||
case r == '<':
|
||||
inTag = true
|
||||
case r == '>':
|
||||
inTag = false
|
||||
case !inTag:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
// snippetAround returns a short text window centered on the first
|
||||
// case-insensitive match of q.
|
||||
func snippetAround(text, q string) string {
|
||||
runes := []rune(text)
|
||||
idx := strings.Index(strings.ToLower(text), strings.ToLower(q))
|
||||
if idx < 0 {
|
||||
if len(runes) > 100 {
|
||||
return string(runes[:100]) + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
start := len([]rune(text[:idx]))
|
||||
from := start - 30
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
to := start + len([]rune(q)) + 50
|
||||
if to > len(runes) {
|
||||
to = len(runes)
|
||||
}
|
||||
out := string(runes[from:to])
|
||||
if from > 0 {
|
||||
out = "…" + out
|
||||
}
|
||||
if to < len(runes) {
|
||||
out += "…"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HandleInboundWS relays ephemeral client events (typing indicator).
|
||||
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
|
||||
if msg.Channel != "chat" || msg.Event != "typing" {
|
||||
return
|
||||
}
|
||||
var data struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &data); err != nil || data.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conv, err := s.store.ConversationByID(ctx, data.ConversationID)
|
||||
if err != nil || !conv.HasParticipant(userID) {
|
||||
return
|
||||
}
|
||||
others := []string{}
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
if pid != userID {
|
||||
others = append(others, pid)
|
||||
}
|
||||
}
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(others, "chat", "typing", map[string]string{
|
||||
"conversationId": conv.ID, "userId": userID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -15,6 +15,12 @@ import (
|
||||
"bountyboard/internal/store"
|
||||
)
|
||||
|
||||
// validThemes are the selectable UI themes (must match web/static/js/theme.js).
|
||||
var validThemes = map[string]bool{
|
||||
"light": true, "dark": true, "neon": true,
|
||||
"terminal": true, "blueprint": true, "sunset": true,
|
||||
}
|
||||
|
||||
func (s *Server) routesProfile(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile)))
|
||||
mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile))
|
||||
@@ -39,7 +45,9 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
||||
Extra *map[string]any `json:"extra"`
|
||||
Settings *struct {
|
||||
Theme *string `json:"theme"`
|
||||
NavLayout *string `json:"navLayout"`
|
||||
Notifications *domain.NotificationPrefs `json:"notifications"`
|
||||
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
|
||||
} `json:"settings"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
@@ -70,15 +78,26 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Settings != nil {
|
||||
if req.Settings.Theme != nil {
|
||||
theme := *req.Settings.Theme
|
||||
if theme != "light" && theme != "dark" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_theme", "theme must be light or dark")
|
||||
if !validThemes[theme] {
|
||||
writeError(w, http.StatusBadRequest, "invalid_theme", "unknown theme")
|
||||
return
|
||||
}
|
||||
set["settings.theme"] = theme
|
||||
}
|
||||
if req.Settings.NavLayout != nil {
|
||||
nav := *req.Settings.NavLayout
|
||||
if nav != "side" && nav != "top" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_nav", "navLayout must be side or top")
|
||||
return
|
||||
}
|
||||
set["settings.navLayout"] = nav
|
||||
}
|
||||
if req.Settings.Notifications != nil {
|
||||
set["settings.notifications"] = *req.Settings.Notifications
|
||||
}
|
||||
if req.Settings.LeaderboardOptOut != nil {
|
||||
set["settings.leaderboardOptOut"] = *req.Settings.LeaderboardOptOut
|
||||
}
|
||||
}
|
||||
if len(set) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update")
|
||||
@@ -193,9 +212,25 @@ func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) {
|
||||
switch meta.Scope {
|
||||
case files.ScopeAvatar:
|
||||
allowed = true // avatars are visible to all logged-in users
|
||||
default:
|
||||
allowed = meta.OwnerID == user.ID || user.Roles.Admin ||
|
||||
user.Roles.Consultant // scoped tighter as chat/task features land
|
||||
case files.ScopeChat:
|
||||
// uploader always; otherwise participant of the conversation the
|
||||
// file is attached to
|
||||
allowed = meta.OwnerID == user.ID
|
||||
if !allowed {
|
||||
ok, err := s.store.UserCanAccessChatFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "chat file access", err)
|
||||
return
|
||||
}
|
||||
allowed = ok
|
||||
}
|
||||
default: // task scope: owner, admins, consultants, or the assignee
|
||||
allowed = meta.OwnerID == user.ID || user.Roles.Admin || user.Roles.Consultant
|
||||
if !allowed && user.Roles.Developer {
|
||||
// developers can fetch task files for tasks they can see;
|
||||
// signed URLs cover the external-service path
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "no access to this file")
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestProfilePatchAndThemePersistence(t *testing.T) {
|
||||
|
||||
// invalid theme rejected
|
||||
resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{
|
||||
"settings": map[string]any{"theme": "neon"},
|
||||
"settings": map[string]any{"theme": "rainbow-unicorn"},
|
||||
}, csrf)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("invalid theme: %d", resp.StatusCode)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"bountyboard/internal/atomize"
|
||||
"bountyboard/internal/domain"
|
||||
@@ -22,10 +23,67 @@ func (s *Server) routesTasks(mux *http.ServeMux) {
|
||||
mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne))
|
||||
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
|
||||
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
|
||||
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
|
||||
mux.Handle("GET /api/v1/archive", s.requireAuth(http.HandlerFunc(s.handleArchive)))
|
||||
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
|
||||
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
|
||||
}
|
||||
|
||||
// handleArchive lists archived tasks scoped by role: admins see everything,
|
||||
// consultants see their customers' tasks, developers see tasks they were
|
||||
// assigned to or worked on. Optional ?q= full-text search.
|
||||
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
q := bson.M{"status": domain.StatusArchived}
|
||||
if !me.Roles.Admin {
|
||||
ors := []bson.M{}
|
||||
if me.Roles.Consultant {
|
||||
cs, err := s.store.ListCustomers(r.Context(), me.ID, true)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "archive customers", err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
ors = append(ors, bson.M{"customerId": bson.M{"$in": ids}})
|
||||
}
|
||||
if me.Roles.Developer {
|
||||
ors = append(ors,
|
||||
bson.M{"assignee.userId": me.ID},
|
||||
bson.M{"claimRequests.developerId": me.ID})
|
||||
}
|
||||
// everyone also sees archived tasks they personally acted on
|
||||
ors = append(ors, bson.M{"timeline.actorId": me.ID})
|
||||
q["$or"] = ors
|
||||
}
|
||||
if search := strings.TrimSpace(r.URL.Query().Get("q")); search != "" {
|
||||
q["$text"] = bson.M{"$search": search}
|
||||
}
|
||||
cur, err := s.store.DB.Collection("tasks").Find(r.Context(), q,
|
||||
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(200))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list archive", err)
|
||||
return
|
||||
}
|
||||
tasks := []domain.Task{}
|
||||
if err := cur.All(r.Context(), &tasks); err != nil {
|
||||
s.internalError(w, r, "decode archive", err)
|
||||
return
|
||||
}
|
||||
// resolve customer names for display
|
||||
names := map[string]string{}
|
||||
for _, t := range tasks {
|
||||
if t.CustomerID != "" && names[t.CustomerID] == "" {
|
||||
if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil {
|
||||
names[t.CustomerID] = c.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customerNames": names})
|
||||
}
|
||||
|
||||
// consultantTask loads a task and authorizes the current user: admins and
|
||||
// every consultant assigned to the task's customer may manage it (§4.4).
|
||||
func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, bool) {
|
||||
@@ -405,6 +463,44 @@ func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleArchiveBulk implements §11.9 bulk archive.
|
||||
func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 || len(req.IDs) > 100 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
|
||||
return
|
||||
}
|
||||
u := CurrentUser(r.Context())
|
||||
archived := []string{}
|
||||
failed := map[string]string{}
|
||||
for _, id := range req.IDs {
|
||||
t, err := s.store.TaskByID(r.Context(), id)
|
||||
if err != nil {
|
||||
failed[id] = "not found"
|
||||
continue
|
||||
}
|
||||
c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
|
||||
if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) {
|
||||
failed[id] = "not your customer"
|
||||
continue
|
||||
}
|
||||
if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
|
||||
failed[id] = err.Error()
|
||||
continue
|
||||
}
|
||||
archived = append(archived, id)
|
||||
}
|
||||
if len(archived) > 0 {
|
||||
s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed})
|
||||
}
|
||||
|
||||
// handleTaskDetail is role-scoped: admins and customer consultants always;
|
||||
// developers when they are the assignee, have a claim, or the task is on the
|
||||
// public board (published/claim_requested).
|
||||
|
||||
+89
-16
@@ -1,13 +1,17 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"bountyboard/api"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/web"
|
||||
)
|
||||
@@ -18,6 +22,8 @@ type pageData struct {
|
||||
Active string // nav highlight key
|
||||
User *domain.User
|
||||
DefaultTheme string
|
||||
NavLayout string
|
||||
Brand string
|
||||
Narrow bool
|
||||
OIDCEnabled bool
|
||||
Error string
|
||||
@@ -77,6 +83,18 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat
|
||||
data.DefaultTheme = data.User.Settings.Theme
|
||||
}
|
||||
}
|
||||
if data.NavLayout == "" {
|
||||
data.NavLayout = "side" // sidebar by default
|
||||
if data.User != nil && data.User.Settings.NavLayout == "top" {
|
||||
data.NavLayout = "top"
|
||||
}
|
||||
}
|
||||
if data.Brand == "" {
|
||||
data.Brand = "Bounty Board"
|
||||
if st, err := s.store.GetSettings(r.Context()); err == nil && st.BrandingName != "" {
|
||||
data.Brand = st.BrandingName
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
s.log.Error("execute template", "page", page, "err", err)
|
||||
@@ -124,8 +142,19 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
if err != nil {
|
||||
panic(err) // embed layout is fixed at compile time
|
||||
}
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/",
|
||||
cacheControl(http.FileServerFS(staticFS), "public, max-age=3600")))
|
||||
// no-cache + a build-wide strong ETag: browsers revalidate on every
|
||||
// load (cheap 304) and can never run a stale CSS/JS mix after a deploy
|
||||
etag := staticETag(staticFS)
|
||||
fileServer := http.StripPrefix("/static/", http.FileServerFS(staticFS))
|
||||
mux.Handle("GET /static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("ETag", etag)
|
||||
fileServer.ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /{$}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "home.html", &pageData{Title: "Home", User: u})
|
||||
@@ -155,6 +184,17 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
})
|
||||
})
|
||||
|
||||
for _, p := range []struct{ path, tmpl, title, script string }{
|
||||
{"/forgot-password", "forgot-password.html", "Forgot password", "/static/js/forgot-password.js"},
|
||||
{"/reset-password", "reset-password.html", "Reset password", "/static/js/reset-password.js"},
|
||||
} {
|
||||
mux.HandleFunc("GET "+p.path, func(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, p.tmpl, &pageData{
|
||||
Title: p.title, Narrow: true, Scripts: []string{p.script},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
mux.HandleFunc("GET /change-password", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "change-password.html", &pageData{
|
||||
Title: "Change password", User: u, Narrow: true,
|
||||
@@ -205,11 +245,15 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
}
|
||||
isDev := func(u *domain.User) bool { return u.Roles.Developer }
|
||||
isCons := func(u *domain.User) bool { return u.Roles.Consultant }
|
||||
rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDev)
|
||||
isDevOrCons := func(u *domain.User) bool { return u.Roles.Developer || u.Roles.Consultant }
|
||||
// Consultants can browse the board too (read-only: they open task detail to
|
||||
// review comments/assignments); only developers see claim actions.
|
||||
rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDevOrCons)
|
||||
rolePage("/my-tasks", "my-tasks.html", "My Tasks", "my-tasks", "/static/js/my-tasks.js", isDev)
|
||||
rolePage("/consultant/reviews", "reviews.html", "Review Queue", "reviews", "/static/js/reviews.js", isCons)
|
||||
rolePage("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons)
|
||||
rolePage("/notifications", "notifications.html", "Notifications", "", "/static/js/notifications-page.js", nil)
|
||||
rolePage("/archive", "archive.html", "Archive", "archive", "/static/js/archive.js", nil)
|
||||
|
||||
mux.HandleFunc("GET /tasks/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "task.html", &pageData{
|
||||
@@ -219,16 +263,34 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
})
|
||||
}))
|
||||
|
||||
// Placeholders for areas built in later phases; replaced as they land.
|
||||
placeholders := map[string]string{
|
||||
"/messages": "Messages",
|
||||
"/metrics": "Metrics",
|
||||
}
|
||||
for path, title := range placeholders {
|
||||
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "placeholder.html", &pageData{Title: title, User: u})
|
||||
mux.HandleFunc("GET /users/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "public-profile.html", &pageData{
|
||||
Title: "Profile", User: u,
|
||||
Scripts: []string{"/static/js/public-profile.js"},
|
||||
Data: map[string]any{"UserID": r.PathValue("id")},
|
||||
})
|
||||
}))
|
||||
|
||||
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
|
||||
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
|
||||
}
|
||||
|
||||
// routesDocs serves the OpenAPI document and a minimal viewer (§6).
|
||||
func (s *Server) routesDocs(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.Write(api.OpenAPI)
|
||||
})
|
||||
mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<title>Bounty Board API</title><link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="/static/js/theme.js"></script></head>
|
||||
<body><main class="container"><h1>Bounty Board API</h1>
|
||||
<p><a class="btn" href="/api/openapi.yaml" download>Download openapi.yaml</a></p>
|
||||
<pre style="white-space:pre-wrap;background:var(--surface);border:1px solid var(--border);padding:16px;border-radius:var(--radius)">` +
|
||||
html.EscapeString(string(api.OpenAPI)) + `</pre></main></body></html>`))
|
||||
})
|
||||
}
|
||||
|
||||
func loginErrorMessage(code string) string {
|
||||
@@ -250,9 +312,20 @@ func loginErrorMessage(code string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func cacheControl(next http.Handler, value string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", value)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
// staticETag hashes every embedded static asset into one build-wide ETag.
|
||||
func staticETag(fsys fs.FS) string {
|
||||
h := sha256.New()
|
||||
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Write([]byte(path))
|
||||
h.Write(data)
|
||||
return nil
|
||||
})
|
||||
return `"` + hex.EncodeToString(h.Sum(nil))[:20] + `"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"bountyboard/internal/ulid"
|
||||
)
|
||||
|
||||
// Conversation per §4.6.
|
||||
type Conversation struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
Kind string `bson:"kind" json:"kind"` // dm | group | project
|
||||
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
|
||||
Title string `bson:"title,omitempty" json:"title,omitempty"`
|
||||
CreatorID string `bson:"creatorId,omitempty" json:"creatorId,omitempty"`
|
||||
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
|
||||
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
|
||||
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
||||
}
|
||||
|
||||
// MessageAttachment per §4.6.
|
||||
type MessageAttachment struct {
|
||||
FileID string `bson:"fileId" json:"fileId"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
MimeType string `bson:"mimeType" json:"mimeType"`
|
||||
Size int64 `bson:"size" json:"size"`
|
||||
IsImage bool `bson:"isImage" json:"isImage"`
|
||||
}
|
||||
|
||||
type ReadReceipt struct {
|
||||
UserID string `bson:"userId" json:"userId"`
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
ConversationID string `bson:"conversationId" json:"conversationId"`
|
||||
SenderID string `bson:"senderId" json:"senderId"`
|
||||
Body string `bson:"body" json:"body"` // sanitized rich text
|
||||
Attachments []MessageAttachment `bson:"attachments" json:"attachments"`
|
||||
ReadBy []ReadReceipt `bson:"readBy" json:"readBy"`
|
||||
EditedAt *time.Time `bson:"editedAt" json:"editedAt"`
|
||||
DeletedAt *time.Time `bson:"deletedAt" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (c *Conversation) HasParticipant(userID string) bool {
|
||||
for _, id := range c.ParticipantIDs {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FindOrCreateDM returns the unique dm between two users, creating it on
|
||||
// first contact (§4.6: participantIds is the dm unique key).
|
||||
func (s *Store) FindOrCreateDM(ctx context.Context, a, b string) (*Conversation, error) {
|
||||
var conv Conversation
|
||||
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{
|
||||
"kind": "dm",
|
||||
"participantIds": bson.M{"$all": []string{a, b}, "$size": 2},
|
||||
}).Decode(&conv)
|
||||
if err == nil {
|
||||
return &conv, nil
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, fmt.Errorf("find dm: %w", err)
|
||||
}
|
||||
conv = Conversation{
|
||||
ID: ulid.New(), Kind: "dm", ParticipantIDs: []string{a, b},
|
||||
LastMessageAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := s.DB.Collection("conversations").InsertOne(ctx, conv); err != nil {
|
||||
return nil, fmt.Errorf("create dm: %w", err)
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateConversation(ctx context.Context, c *Conversation) error {
|
||||
c.ID = ulid.New()
|
||||
c.CreatedAt = time.Now().UTC()
|
||||
c.LastMessageAt = c.CreatedAt
|
||||
if _, err := s.DB.Collection("conversations").InsertOne(ctx, c); err != nil {
|
||||
return fmt.Errorf("create conversation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation, error) {
|
||||
var c Conversation
|
||||
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{"_id": id}).Decode(&c)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find conversation: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// AddParticipant adds a user to a conversation (idempotent via $addToSet).
|
||||
func (s *Store) AddParticipant(ctx context.Context, convID, userID string) error {
|
||||
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||
bson.M{"_id": convID},
|
||||
bson.M{"$addToSet": bson.M{"participantIds": userID}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("add participant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveParticipant removes a user from a conversation.
|
||||
func (s *Store) RemoveParticipant(ctx context.Context, convID, userID string) error {
|
||||
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||
bson.M{"_id": convID},
|
||||
bson.M{"$pull": bson.M{"participantIds": userID}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove participant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
|
||||
cur, err := s.DB.Collection("conversations").Find(ctx,
|
||||
bson.M{"participantIds": userID},
|
||||
options.Find().SetSort(bson.D{{Key: "lastMessageAt", Value: -1}}).SetLimit(100))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list conversations: %w", err)
|
||||
}
|
||||
out := []Conversation{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode conversations: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnreadCounts returns per-conversation unread message counts for the user.
|
||||
func (s *Store) UnreadCounts(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
|
||||
if len(convIDs) == 0 {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
cur, err := s.DB.Collection("messages").Aggregate(ctx, mongo.Pipeline{
|
||||
{{Key: "$match", Value: bson.M{
|
||||
"conversationId": bson.M{"$in": convIDs},
|
||||
"senderId": bson.M{"$ne": userID},
|
||||
"deletedAt": nil,
|
||||
"readBy.userId": bson.M{"$ne": userID},
|
||||
}}},
|
||||
{{Key: "$group", Value: bson.M{"_id": "$conversationId", "n": bson.M{"$sum": 1}}}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unread counts: %w", err)
|
||||
}
|
||||
var rows []struct {
|
||||
ID string `bson:"_id"`
|
||||
N int64 `bson:"n"`
|
||||
}
|
||||
if err := cur.All(ctx, &rows); err != nil {
|
||||
return nil, fmt.Errorf("decode unread: %w", err)
|
||||
}
|
||||
out := make(map[string]int64, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.ID] = r.N
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertMessage stores the message and bumps the conversation timestamp.
|
||||
func (s *Store) InsertMessage(ctx context.Context, m *Message) error {
|
||||
m.ID = ulid.New()
|
||||
if m.Attachments == nil {
|
||||
m.Attachments = []MessageAttachment{}
|
||||
}
|
||||
m.ReadBy = []ReadReceipt{{UserID: m.SenderID, At: time.Now().UTC()}}
|
||||
if _, err := s.DB.Collection("messages").InsertOne(ctx, m); err != nil {
|
||||
return fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||
bson.M{"_id": m.ConversationID},
|
||||
bson.M{"$set": bson.M{"lastMessageAt": time.Now().UTC()}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("bump conversation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMessages returns up to limit messages older than cursor (ULID order),
|
||||
// newest last.
|
||||
func (s *Store) ListMessages(ctx context.Context, convID, cursor string, limit int) ([]Message, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
q := bson.M{"conversationId": convID}
|
||||
if cursor != "" {
|
||||
q["_id"] = bson.M{"$lt": cursor}
|
||||
}
|
||||
cur, err := s.DB.Collection("messages").Find(ctx, q,
|
||||
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(int64(limit)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list messages: %w", err)
|
||||
}
|
||||
out := []Message{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode messages: %w", err)
|
||||
}
|
||||
// reverse to chronological order
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkConversationRead adds a read receipt to every unread message.
|
||||
func (s *Store) MarkConversationRead(ctx context.Context, convID, userID string) (int64, error) {
|
||||
res, err := s.DB.Collection("messages").UpdateMany(ctx,
|
||||
bson.M{
|
||||
"conversationId": convID,
|
||||
"readBy.userId": bson.M{"$ne": userID},
|
||||
},
|
||||
bson.M{"$push": bson.M{"readBy": ReadReceipt{UserID: userID, At: time.Now().UTC()}}})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("mark read: %w", err)
|
||||
}
|
||||
return res.ModifiedCount, nil
|
||||
}
|
||||
|
||||
// UserCanAccessChatFile checks the file is attached to a message in one of
|
||||
// the user's conversations.
|
||||
func (s *Store) UserCanAccessChatFile(ctx context.Context, userID, fileID string) (bool, error) {
|
||||
var msg Message
|
||||
err := s.DB.Collection("messages").FindOne(ctx,
|
||||
bson.M{"attachments.fileId": fileID}).Decode(&msg)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("find file message: %w", err)
|
||||
}
|
||||
conv, err := s.ConversationByID(ctx, msg.ConversationID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return conv.HasParticipant(userID), nil
|
||||
}
|
||||
@@ -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}}},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
+26
-8
@@ -2,9 +2,9 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -47,23 +47,41 @@ type jiraIssue struct {
|
||||
} `json:"fields"`
|
||||
}
|
||||
|
||||
// search runs a JQL query via POST /rest/api/3/search/jql — the replacement
|
||||
// for the removed GET /rest/api/3/search endpoint (Atlassian CHANGE-2046).
|
||||
// It uses cursor pagination (nextPageToken/isLast); the legacy startAt/total
|
||||
// fields no longer exist.
|
||||
func (j *jiraConnector) search(ctx context.Context, jql, fields string) ([]jiraIssue, error) {
|
||||
fieldList := strings.Split(fields, ",")
|
||||
u := j.baseURL + "/rest/api/3/search/jql"
|
||||
var all []jiraIssue
|
||||
for startAt := 0; ; {
|
||||
nextPageToken := ""
|
||||
for {
|
||||
reqBody := map[string]any{
|
||||
"jql": jql,
|
||||
"fields": fieldList,
|
||||
"maxResults": 100,
|
||||
}
|
||||
if nextPageToken != "" {
|
||||
reqBody["nextPageToken"] = nextPageToken
|
||||
}
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jira search: encode request: %w", err)
|
||||
}
|
||||
var page struct {
|
||||
Issues []jiraIssue `json:"issues"`
|
||||
Total int `json:"total"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
IsLast bool `json:"isLast"`
|
||||
}
|
||||
u := fmt.Sprintf("%s/rest/api/3/search?jql=%s&fields=%s&maxResults=100&startAt=%d",
|
||||
j.baseURL, url.QueryEscape(jql), url.QueryEscape(fields), startAt)
|
||||
if err := getJSON(ctx, j.Authorize, u, &page); err != nil {
|
||||
if err := doJSON(ctx, j.Authorize, http.MethodPost, u, body, &page); err != nil {
|
||||
return nil, fmt.Errorf("jira search: %w", err)
|
||||
}
|
||||
all = append(all, page.Issues...)
|
||||
startAt += len(page.Issues)
|
||||
if len(page.Issues) == 0 || startAt >= page.Total {
|
||||
if page.IsLast || page.NextPageToken == "" || len(page.Issues) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
nextPageToken = page.NextPageToken
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/files"
|
||||
"bountyboard/internal/metrics"
|
||||
@@ -255,7 +257,12 @@ func (m *Manager) SyncCustomer(ctx context.Context, c *domain.Customer) error {
|
||||
if err != nil {
|
||||
continue // consultant deleted; reconcile will catch up
|
||||
}
|
||||
identity := ticketingIdentity(consultant, c.Ticketing.Type)
|
||||
// Per-customer mapping (set by the admin on the customer) wins over
|
||||
// the consultant's global identity; fall back to the global one.
|
||||
identity := c.Ticketing.ConsultantIdentities[consultantID]
|
||||
if identity == "" {
|
||||
identity = ticketingIdentity(consultant, c.Ticketing.Type)
|
||||
}
|
||||
if identity == "" {
|
||||
continue // §5.3: consultant has no linked account in this system
|
||||
}
|
||||
@@ -287,12 +294,24 @@ func ticketingIdentity(u *domain.User, t domain.TicketingType) string {
|
||||
if t == domain.TicketingDemo {
|
||||
return u.Email // demo accepts any identity
|
||||
}
|
||||
ids, _ := u.Extra["ticketingIdentities"].(map[string]any)
|
||||
if ids == nil {
|
||||
return ""
|
||||
}
|
||||
// The driver decodes the nested document as bson.D when the field type
|
||||
// is `any`; JSON-built values arrive as map[string]any. Handle both.
|
||||
switch ids := u.Extra["ticketingIdentities"].(type) {
|
||||
case map[string]any:
|
||||
v, _ := ids[string(t)].(string)
|
||||
return v
|
||||
case bson.M:
|
||||
v, _ := ids[string(t)].(string)
|
||||
return v
|
||||
case bson.D:
|
||||
for _, e := range ids {
|
||||
if e.Key == string(t) {
|
||||
v, _ := e.Value.(string)
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func contentHash(t Ticket) string {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Executable
+168
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
module atomizer-mock
|
||||
|
||||
go 1.26
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env node
|
||||
// work-performer: standalone placeholder Work Performer Service (§5.2, §9.2).
|
||||
// Plain Node http server, no framework. For each job it prepares
|
||||
// /work/{jobId}/TASK.md and runs the Claude Code CLI; if the CLI is missing
|
||||
// or fails to start (e.g. no credentials mounted), it produces a simulated
|
||||
// result so the whole flow stays demonstrable offline. The HTTP contract —
|
||||
// not this implementation — is the deliverable.
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile, execFileSync } = require('child_process');
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '8091', 10);
|
||||
const TOKEN = process.env.WORK_PERFORMER_TOKEN || '';
|
||||
const WORK_DIR = process.env.WORK_DIR || '/work';
|
||||
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || `http://work-performer:${PORT}`;
|
||||
|
||||
const jobs = new Map(); // jobId -> {status, taskId, request, startedAt, finishedAt, cancel}
|
||||
const queue = [];
|
||||
let running = false; // single-job concurrency (§9.2)
|
||||
|
||||
const log = (msg, extra) =>
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), msg, ...extra }));
|
||||
|
||||
function json(res, status, body) {
|
||||
const data = JSON.stringify(body);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(data);
|
||||
}
|
||||
const errJson = (res, status, code, message) =>
|
||||
json(res, status, { error: { code, message } });
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on('data', (c) => {
|
||||
size += c.length;
|
||||
if (size > 4 << 20) { reject(new Error('body too large')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function download(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith('https:') ? https : http;
|
||||
const file = fs.createWriteStream(dest);
|
||||
mod.get(url, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
file.close(); fs.rmSync(dest, { force: true });
|
||||
reject(new Error(`download ${url}: status ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(resolve));
|
||||
}).on('error', (e) => { file.close(); fs.rmSync(dest, { force: true }); reject(e); });
|
||||
});
|
||||
}
|
||||
|
||||
function postCallback(urlStr, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = Buffer.from(JSON.stringify(payload));
|
||||
const sig = crypto.createHmac('sha256', TOKEN).update(body).digest('hex');
|
||||
const url = new URL(urlStr);
|
||||
const mod = url.protocol === 'https:' ? https : http;
|
||||
const req = mod.request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': body.length,
|
||||
'X-Signature': sig,
|
||||
},
|
||||
}, (res) => { res.resume(); resolve(res.statusCode); });
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
function claudeAvailable() {
|
||||
try {
|
||||
execFileSync('claude', ['--version'], { timeout: 15000, stdio: 'pipe' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskMD(r) {
|
||||
const lines = [`# ${r.title}`, '', r.description || '', ''];
|
||||
if ((r.acceptanceCriteria || []).length) {
|
||||
lines.push('## Acceptance criteria', '');
|
||||
r.acceptanceCriteria.forEach((c) => lines.push(`- ${c}`));
|
||||
lines.push('');
|
||||
}
|
||||
if ((r.links || []).length) {
|
||||
lines.push('## Links', '');
|
||||
r.links.forEach((l) => lines.push(`- ${l}`));
|
||||
lines.push('');
|
||||
}
|
||||
if ((r.attachments || []).length) {
|
||||
lines.push('## Attachments (downloaded into ./attachments)', '');
|
||||
r.attachments.forEach((a) => lines.push(`- ${a.name}`));
|
||||
lines.push('');
|
||||
}
|
||||
if (r.context && r.context.instructions) {
|
||||
lines.push('## Extra instructions', '', r.context.instructions, '');
|
||||
}
|
||||
lines.push('Produce your changes in this directory. Write a SUMMARY.md describing what you did.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function listProducedFiles(dir, before) {
|
||||
const out = [];
|
||||
const walk = (d) => {
|
||||
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (entry.name === '.git' || entry.name === 'attachments') continue;
|
||||
const full = path.join(d, entry.name);
|
||||
if (entry.isDirectory()) { walk(full); continue; }
|
||||
const rel = path.relative(dir, full);
|
||||
if (!before.has(rel) && fs.statSync(full).size <= 20 << 20) out.push(rel);
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return out.slice(0, 20);
|
||||
}
|
||||
|
||||
async function runJob(jobId) {
|
||||
const job = jobs.get(jobId);
|
||||
if (!job || job.status !== 'queued') return;
|
||||
job.status = 'running';
|
||||
job.startedAt = new Date().toISOString();
|
||||
const r = job.request;
|
||||
const dir = path.join(WORK_DIR, jobId);
|
||||
let result;
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, 'attachments'), { recursive: true });
|
||||
|
||||
// optional repo clone (§9.2)
|
||||
if (r.context && r.context.repositoryUrl) {
|
||||
const args = ['clone', '--depth', '1'];
|
||||
if (r.context.branch) args.push('-b', r.context.branch);
|
||||
args.push(r.context.repositoryUrl, path.join(dir, 'repo'));
|
||||
await new Promise((resolve) => {
|
||||
execFile('git', args, { timeout: 120000 }, (err, _o, stderr) => {
|
||||
if (err) log('git clone failed', { jobId, err: String(stderr || err) });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const a of r.attachments || []) {
|
||||
try {
|
||||
await download(a.url, path.join(dir, 'attachments', path.basename(a.name)));
|
||||
} catch (e) { log('attachment download failed', { jobId, name: a.name, err: e.message }); }
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, 'TASK.md'), buildTaskMD(r));
|
||||
const before = new Set(['TASK.md']);
|
||||
|
||||
if (claudeAvailable()) {
|
||||
result = await new Promise((resolve) => {
|
||||
const child = execFile('claude',
|
||||
['-p', fs.readFileSync(path.join(dir, 'TASK.md'), 'utf8'),
|
||||
'--output-format', 'json', '--dangerously-skip-permissions'],
|
||||
{ cwd: dir, timeout: 30 * 60 * 1000, maxBuffer: 32 << 20 },
|
||||
(err, stdout, stderr) => {
|
||||
if (job.cancel) { resolve({ status: 'failed', summary: 'job canceled', log: '' }); return; }
|
||||
if (err) {
|
||||
resolve({ status: 'failed', summary: `claude execution failed: ${err.message}`,
|
||||
log: String(stderr || '').slice(-4000) });
|
||||
return;
|
||||
}
|
||||
let summary = 'Claude Code completed the task.';
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
summary = parsed.result || parsed.summary || summary;
|
||||
} catch (e) { summary = String(stdout).slice(0, 1000) || summary; }
|
||||
resolve({ status: 'succeeded', summary: String(summary).slice(0, 4000),
|
||||
log: String(stdout).slice(-4000) });
|
||||
});
|
||||
job.kill = () => child.kill('SIGTERM');
|
||||
});
|
||||
} else {
|
||||
// offline placeholder result keeps the end-to-end flow demonstrable
|
||||
const summary = `Simulated work performer result (Claude Code CLI not available in this container).\n` +
|
||||
`Reviewed task "${r.title}" with ${(r.acceptanceCriteria || []).length} acceptance criteria.`;
|
||||
fs.writeFileSync(path.join(dir, 'SUMMARY.md'),
|
||||
`# Simulated result\n\n${summary}\n\nThis placeholder proves the §5.2 contract end to end.`);
|
||||
result = { status: 'succeeded', summary, log: 'claude CLI unavailable; produced simulated SUMMARY.md' };
|
||||
}
|
||||
|
||||
const artifacts = listProducedFiles(dir, before).map((rel) => ({
|
||||
name: rel.replace(/\//g, '_'),
|
||||
url: `${PUBLIC_BASE}/artifacts/${jobId}/${encodeURIComponent(rel)}`,
|
||||
}));
|
||||
result.artifacts = artifacts;
|
||||
} catch (e) {
|
||||
result = { status: 'failed', summary: `job crashed: ${e.message}`, log: '', artifacts: [] };
|
||||
}
|
||||
|
||||
job.status = result.status;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
const payload = {
|
||||
jobId, taskId: r.taskId, status: result.status,
|
||||
summary: result.summary, artifacts: result.artifacts || [], log: result.log || '',
|
||||
};
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const code = await postCallback(r.callbackUrl, payload);
|
||||
log('callback delivered', { jobId, code });
|
||||
break;
|
||||
} catch (e) {
|
||||
log('callback failed', { jobId, attempt, err: e.message });
|
||||
await new Promise((s) => setTimeout(s, attempt * 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pump() {
|
||||
if (running) return;
|
||||
const next = queue.shift();
|
||||
if (!next) return;
|
||||
running = true;
|
||||
try { await runJob(next); } finally {
|
||||
running = false;
|
||||
setImmediate(pump);
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/healthz') {
|
||||
return json(res, 200, { status: 'ok' });
|
||||
}
|
||||
|
||||
// artifact downloads are unauthenticated within the compose network
|
||||
const artMatch = url.pathname.match(/^\/artifacts\/([\w]+)\/(.+)$/);
|
||||
if (req.method === 'GET' && artMatch) {
|
||||
const file = path.join(WORK_DIR, artMatch[1], decodeURIComponent(artMatch[2]));
|
||||
if (!file.startsWith(path.join(WORK_DIR, artMatch[1]) + path.sep) || !fs.existsSync(file)) {
|
||||
return errJson(res, 404, 'not_found', 'artifact not found');
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
|
||||
return fs.createReadStream(file).pipe(res);
|
||||
}
|
||||
|
||||
if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) {
|
||||
return errJson(res, 401, 'unauthorized', 'missing or invalid bearer token');
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/v1/jobs') {
|
||||
let body;
|
||||
try { body = JSON.parse(await readBody(req)); }
|
||||
catch (e) { return errJson(res, 400, 'bad_request', e.message); }
|
||||
if (!body.taskId || !body.title || !body.callbackUrl) {
|
||||
return errJson(res, 400, 'bad_request', 'taskId, title and callbackUrl are required');
|
||||
}
|
||||
const jobId = 'wp_' + crypto.randomBytes(8).toString('hex');
|
||||
jobs.set(jobId, { status: 'queued', taskId: body.taskId, request: body,
|
||||
startedAt: null, finishedAt: null, cancel: false });
|
||||
queue.push(jobId);
|
||||
setImmediate(pump);
|
||||
log('job queued', { jobId, taskId: body.taskId });
|
||||
return json(res, 202, { jobId, status: 'queued' });
|
||||
}
|
||||
|
||||
const jobMatch = url.pathname.match(/^\/v1\/jobs\/(wp_[\w]+)$/);
|
||||
if (jobMatch) {
|
||||
const job = jobs.get(jobMatch[1]);
|
||||
if (!job) return errJson(res, 404, 'not_found', 'job not found');
|
||||
if (req.method === 'GET') {
|
||||
return json(res, 200, { jobId: jobMatch[1], status: job.status,
|
||||
startedAt: job.startedAt, finishedAt: job.finishedAt });
|
||||
}
|
||||
if (req.method === 'DELETE') { // best-effort cancel (§5.2)
|
||||
job.cancel = true;
|
||||
const idx = queue.indexOf(jobMatch[1]);
|
||||
if (idx >= 0) { queue.splice(idx, 1); job.status = 'failed'; job.finishedAt = new Date().toISOString(); }
|
||||
if (job.kill) try { job.kill(); } catch (e) { /* already gone */ }
|
||||
return json(res, 200, { jobId: jobMatch[1], status: 'cancel_requested' });
|
||||
}
|
||||
}
|
||||
|
||||
errJson(res, 404, 'not_found', 'unknown endpoint');
|
||||
});
|
||||
|
||||
server.listen(PORT, () => log('work-performer listening', { port: PORT, claude: claudeAvailable() }));
|
||||
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
||||
+1
-1
@@ -441,7 +441,7 @@ account** in that system, updated since `lastSyncAt − 5 min` (overlap window):
|
||||
|
||||
| System | Query mechanism |
|
||||
|---|---|
|
||||
| Jira Cloud | `GET /rest/api/3/search?jql=assignee="<email>" AND project=<key> AND updated >= "<ts>"` (Basic auth: email+API token) |
|
||||
| Jira Cloud | `POST /rest/api/3/search/jql` with body `{jql:'assignee="<email>" AND project=<key> AND updated >= "<ts>"', fields, maxResults}`, cursor-paginated via `nextPageToken`/`isLast` (Basic auth: email+API token). The legacy `GET /rest/api/3/search` was removed by Atlassian — CHANGE-2046. |
|
||||
| Azure DevOps | `POST …/_apis/wit/wiql?api-version=7.1` with WIQL `[System.AssignedTo] = '<email>' AND [System.TeamProject] = '<project>' AND [System.ChangedDate] >= '<ts>'`, then batch `GET workitems` (PAT auth) |
|
||||
| YouTrack | `GET /api/issues?query=assignee: <login> project: <key> updated: <ts> .. *` (Bearer permanent token) |
|
||||
|
||||
|
||||
+814
-88
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,6 +25,8 @@ function credsFromForm(type) {
|
||||
return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') };
|
||||
case 'youtrack':
|
||||
return { permanentToken: val('c-yt-token') };
|
||||
case 'wekan':
|
||||
return { username: val('c-wekan-user'), password: val('c-wekan-pass') };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -55,6 +57,36 @@ async function loadConsultants() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Render one username input per selected consultant, prefilled from the
|
||||
// customer's saved per-consultant ticketing identity map.
|
||||
function renderIdentities() {
|
||||
const wrap = document.getElementById('c-identities-wrap');
|
||||
const host = document.getElementById('c-identities');
|
||||
const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value);
|
||||
const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {};
|
||||
if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; }
|
||||
wrap.hidden = false;
|
||||
host.replaceChildren(...selectedIds.map((id) => {
|
||||
const u = consultants.find((c) => c.id === id) || { name: id, email: '' };
|
||||
const row = document.createElement('div');
|
||||
row.className = 'spread';
|
||||
row.style.marginTop = '6px';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'muted';
|
||||
label.style.flex = '1';
|
||||
label.textContent = u.name;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.dataset.identity = id;
|
||||
inp.placeholder = 'username in ticketing system';
|
||||
inp.value = existing[id] || '';
|
||||
inp.style.flex = '1';
|
||||
row.append(label, inp);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
document.getElementById('c-consultants').addEventListener('change', renderIdentities);
|
||||
|
||||
function openCustomerDialog(customer) {
|
||||
editingCustomer = customer || null;
|
||||
document.getElementById('customer-dialog-title').textContent =
|
||||
@@ -71,6 +103,7 @@ function openCustomerDialog(customer) {
|
||||
[...sel.options].forEach((o) => {
|
||||
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
|
||||
});
|
||||
renderIdentities();
|
||||
document.getElementById('c-test-result').textContent =
|
||||
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
|
||||
dlg.showModal();
|
||||
@@ -112,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) =>
|
||||
defaultBudget: parseFloat(val('c-budget')) || 0,
|
||||
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
|
||||
};
|
||||
const identities = {};
|
||||
document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => {
|
||||
if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim();
|
||||
});
|
||||
body.consultantIdentities = identities;
|
||||
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
|
||||
try {
|
||||
if (editingCustomer) {
|
||||
@@ -324,6 +362,7 @@ async function loadAudit(reset = true) {
|
||||
try {
|
||||
const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim());
|
||||
if (reset) auditCursor = '';
|
||||
else if (!auditCursor) return; // already at the last page
|
||||
const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`);
|
||||
auditCursor = res.nextCursor;
|
||||
const rows = res.entries.map((e) => {
|
||||
@@ -342,6 +381,7 @@ async function loadAudit(reset = true) {
|
||||
const tbody = document.querySelector('#audit-table tbody');
|
||||
if (reset) tbody.replaceChildren(...rows);
|
||||
else tbody.append(...rows);
|
||||
document.getElementById('audit-more').hidden = !auditCursor;
|
||||
} catch (e) { showErr(e); }
|
||||
}
|
||||
document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true));
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Archive page: archived tasks scoped by role (server-side), with search.
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
const host = document.getElementById('archive-list');
|
||||
const errorBox = document.getElementById('error');
|
||||
const search = document.getElementById('archive-search');
|
||||
let names = {};
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
function render(tasks) {
|
||||
host.replaceChildren(...tasks.map((t) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const when = t.updatedAt ? new Date(t.updatedAt).toLocaleString() : '';
|
||||
card.innerHTML = `
|
||||
<div class="spread">
|
||||
${names[t.customerId] ? `<span class="card-project">${esc(names[t.customerId])}</span>` : '<span></span>'}
|
||||
<span class="badge accent">◈ ${t.bounty}</span>
|
||||
</div>
|
||||
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||
<p class="muted">${esc((t.description || '').slice(0, 200))}</p>
|
||||
<p class="muted" style="font-size:0.8rem">archived${when ? ' · ' + esc(when) : ''}</p>`;
|
||||
return card;
|
||||
}));
|
||||
if (!tasks.length) {
|
||||
host.innerHTML = '<p class="muted">No archived tasks match.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
async function load() {
|
||||
try {
|
||||
const q = search.value.trim();
|
||||
const res = await api('GET', '/api/v1/archive' + (q ? '?q=' + encodeURIComponent(q) : ''));
|
||||
names = res.customerNames || {};
|
||||
render(res.tasks || []);
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
search.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 250); });
|
||||
load();
|
||||
@@ -11,6 +11,12 @@ let tasks = [];
|
||||
let atomizerUp = true;
|
||||
const selected = new Set();
|
||||
|
||||
// Hold Ctrl/Cmd while dragging an effort slider to rebalance siblings.
|
||||
let ctrlHeld = false;
|
||||
window.addEventListener('keydown', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = true; });
|
||||
window.addEventListener('keyup', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = false; });
|
||||
window.addEventListener('blur', () => { ctrlHeld = false; });
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
@@ -82,7 +88,7 @@ function renderRoot(root) {
|
||||
? '<span class="badge" style="color:var(--err)" title="No longer returned by the upstream system">orphaned</span>' : '';
|
||||
card.innerHTML = `
|
||||
<div class="spread">
|
||||
<h3>${icon} ${esc(root.title)} ${statusBadge(root)} ${orphan}</h3>
|
||||
<h3>${icon} <a href="/tasks/${root.id}">${esc(root.title)}</a> ${statusBadge(root)} ${orphan}</h3>
|
||||
<span class="muted">${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}</span>
|
||||
</div>
|
||||
<p class="muted">${esc((root.description || '').slice(0, 240))}</p>
|
||||
@@ -115,14 +121,20 @@ function renderChildren(parent, kids, depth) {
|
||||
wrap.className = 'stack mt';
|
||||
|
||||
const subdivided = kids.filter((k) => k.origin === 'subdivided');
|
||||
const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0);
|
||||
const hasExtension = kids.some((k) => k.origin === 'extended');
|
||||
if (subdivided.length) {
|
||||
const ind = document.createElement('p');
|
||||
const bad = !hasExtension && Math.abs(sum - 1) > 0.005;
|
||||
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
|
||||
const subSiblings = []; // {k, slider, coeffEl, bountyEl} for adjustable subdivided rows
|
||||
let ind = null;
|
||||
function updateSumIndicator() {
|
||||
if (!ind) return;
|
||||
const s = subSiblings.reduce((a, x) => a + parseFloat(x.slider.value), 0);
|
||||
const bad = !hasExtension && Math.abs(s - 1) > 0.005;
|
||||
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${s.toFixed(2)}</strong>` +
|
||||
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
|
||||
: bad ? ' — should be 1.00' : '');
|
||||
: bad ? ' — should be 1.00' : '') +
|
||||
' <span class="muted" style="font-size:0.82rem">· hold Ctrl while dragging to rebalance the others</span>';
|
||||
}
|
||||
if (subdivided.length) {
|
||||
ind = document.createElement('p');
|
||||
wrap.appendChild(ind);
|
||||
}
|
||||
|
||||
@@ -135,7 +147,7 @@ function renderChildren(parent, kids, depth) {
|
||||
<div class="spread">
|
||||
<span>
|
||||
${k.status === 'atomized' ? `<input type="checkbox" data-sel aria-label="Select ${esc(k.title)} for publishing" ${selected.has(k.id) ? 'checked' : ''}>` : ''}
|
||||
<strong>${esc(k.title)}</strong> ${ext} ${statusBadge(k)}
|
||||
<a href="/tasks/${k.id}"><strong>${esc(k.title)}</strong></a> ${ext} ${statusBadge(k)}
|
||||
</span>
|
||||
<span>bounty <strong data-bounty>${k.bounty}</strong></span>
|
||||
</div>
|
||||
@@ -149,31 +161,62 @@ function renderChildren(parent, kids, depth) {
|
||||
</label>
|
||||
<span>
|
||||
<button class="btn small" data-act="edit">Edit</button>
|
||||
${['imported', 'atomized'].includes(k.status)
|
||||
? '<button class="btn small" data-act="subdivide" data-needs-atomizer>Subdivide</button>' : ''}
|
||||
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
|
||||
${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
|
||||
<button class="btn small" data-act="archive">Archive</button>
|
||||
</span>
|
||||
</div>`;
|
||||
const slider = row.querySelector('input[type=range]');
|
||||
const coeffEl = row.querySelector('[data-coeff]');
|
||||
const bountyEl = row.querySelector('[data-bounty]');
|
||||
if (k.origin === 'subdivided' && !slider.disabled) {
|
||||
subSiblings.push({ k, slider, coeffEl, bountyEl });
|
||||
}
|
||||
let sliderTimer = null;
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
slider.addEventListener('input', () => {
|
||||
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2);
|
||||
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2);
|
||||
const newVal = parseFloat(slider.value);
|
||||
coeffEl.textContent = newVal.toFixed(2);
|
||||
bountyEl.textContent = (newVal * k.budget).toFixed(2);
|
||||
const changed = [{ k, value: newVal }];
|
||||
// Ctrl/Cmd held: rebalance the other subdivided siblings so the sum
|
||||
// stays ~1.00 (increase one → the rest shrink proportionally).
|
||||
if (ctrlHeld && k.origin === 'subdivided') {
|
||||
const others = subSiblings.filter((s) => s.k.id !== k.id);
|
||||
const curOthers = others.reduce((a, s) => a + parseFloat(s.slider.value), 0);
|
||||
const targetOthers = Math.max(0, 1 - newVal);
|
||||
if (others.length && curOthers > 0) {
|
||||
const scale = targetOthers / curOthers;
|
||||
others.forEach((s) => {
|
||||
const max = parseFloat(s.slider.max);
|
||||
const nv = round2(Math.max(0.01, Math.min(max, parseFloat(s.slider.value) * scale)));
|
||||
s.slider.value = nv;
|
||||
s.coeffEl.textContent = nv.toFixed(2);
|
||||
s.bountyEl.textContent = (nv * s.k.budget).toFixed(2);
|
||||
changed.push({ k: s.k, value: nv });
|
||||
});
|
||||
}
|
||||
updateSumIndicator();
|
||||
}
|
||||
clearTimeout(sliderTimer);
|
||||
sliderTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, {
|
||||
effortCoefficient: parseFloat(slider.value), version: k.version,
|
||||
for (const ch of changed) {
|
||||
const res = await api('PATCH', `/api/v1/tasks/${ch.k.id}`, {
|
||||
effortCoefficient: ch.value, version: ch.k.version,
|
||||
});
|
||||
k.version = res.task.version;
|
||||
k.effortCoefficient = res.task.effortCoefficient;
|
||||
k.bounty = res.task.bounty;
|
||||
ch.k.version = res.task.version;
|
||||
ch.k.effortCoefficient = res.task.effortCoefficient;
|
||||
ch.k.bounty = res.task.bounty;
|
||||
}
|
||||
render();
|
||||
} catch (e) {
|
||||
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
|
||||
else toast(e.message, 'err');
|
||||
}
|
||||
}, 400);
|
||||
}, 450);
|
||||
});
|
||||
const sel = row.querySelector('[data-sel]');
|
||||
if (sel) {
|
||||
@@ -183,6 +226,8 @@ function renderChildren(parent, kids, depth) {
|
||||
});
|
||||
}
|
||||
row.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(k, false));
|
||||
const sd = row.querySelector('[data-act=subdivide]');
|
||||
if (sd) sd.addEventListener('click', () => openSubdivide(k));
|
||||
row.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(k));
|
||||
row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k));
|
||||
const pub = row.querySelector('[data-act=publish]');
|
||||
@@ -196,6 +241,7 @@ function renderChildren(parent, kids, depth) {
|
||||
const grand = childrenOf(k.id);
|
||||
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
|
||||
});
|
||||
updateSumIndicator();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
const grid = document.getElementById('board-grid');
|
||||
const errorBox = document.getElementById('error');
|
||||
let meId = '';
|
||||
let isDeveloper = false;
|
||||
let tasks = [];
|
||||
const customerNames = new Map();
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
@@ -25,6 +27,7 @@ async function load() {
|
||||
const qs = new URLSearchParams(f).toString();
|
||||
const res = await api('GET', '/api/v1/board?' + qs);
|
||||
tasks = res.tasks;
|
||||
(res.customers || []).forEach((c) => customerNames.set(c.id, c.name));
|
||||
const sel = document.getElementById('f-customer');
|
||||
if (sel.options.length === 1) {
|
||||
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
|
||||
@@ -58,6 +61,7 @@ function renderCard(t) {
|
||||
<span class="badge accent" style="font-size:1rem">◈ ${t.bounty}</span>
|
||||
<span>${ageBadge(t)}</span>
|
||||
</div>
|
||||
${customerNames.has(t.customerId) ? `<p class="muted card-project">${esc(customerNames.get(t.customerId))}</p>` : ''}
|
||||
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||
<p class="muted">${esc((t.description || '').slice(0, 180))}</p>
|
||||
<p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p>
|
||||
@@ -66,7 +70,9 @@ function renderCard(t) {
|
||||
${myClaim ? '<span class="badge" style="color:var(--ok)">requested by you</span>' : ''}
|
||||
${others ? `<span class="badge">${others} other request(s)</span>` : ''}
|
||||
</span>
|
||||
${myClaim
|
||||
${!isDeveloper
|
||||
? '<a class="btn small" href="/tasks/' + t.id + '">Open</a>'
|
||||
: myClaim
|
||||
? '<button class="btn small" data-act="withdraw">Withdraw</button>'
|
||||
: '<button class="btn primary small" data-act="claim">Request assignment</button>'}
|
||||
</div>`;
|
||||
@@ -118,6 +124,7 @@ async function restoreFilters() {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
meId = me.user.id;
|
||||
isDeveloper = !!(me.user.roles && me.user.roles.developer);
|
||||
const saved = me.user.extra && me.user.extra.savedBoardFilters;
|
||||
if (saved) {
|
||||
const f = JSON.parse(saved);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Floating messages bubble (bottom-right, every page except /messages):
|
||||
// unread badge, mini panel with conversation list ↔ thread view and a quick
|
||||
// plain-text composer. Reuses the conversations API + live WS channel.
|
||||
import { api } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
import { attachMentions, mentionHTML } from '/static/js/mention.js';
|
||||
|
||||
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
|
||||
let meId = '';
|
||||
let open = false;
|
||||
let current = null; // conversation object when in thread view
|
||||
const userCache = new Map();
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
const fab = document.createElement('button');
|
||||
fab.className = 'chat-fab';
|
||||
fab.setAttribute('aria-label', 'Messages');
|
||||
fab.innerHTML = '✉<span class="bell-badge" id="cw-unread" hidden></span>';
|
||||
document.body.appendChild(fab);
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'chat-panel';
|
||||
panel.hidden = true;
|
||||
panel.innerHTML = `
|
||||
<header>
|
||||
<span style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<button class="btn small" id="cw-back" hidden aria-label="Back to conversations">← Back</button>
|
||||
<strong id="cw-title">Messages</strong>
|
||||
</span>
|
||||
<span style="display:flex;gap:6px">
|
||||
<a class="btn small" id="cw-full" href="/messages" aria-label="Open full messages" title="Open full messages">⤢</a>
|
||||
<button class="btn small" id="cw-close" aria-label="Close" title="Close">✕</button>
|
||||
</span>
|
||||
</header>
|
||||
<div class="cw-body" id="cw-body"></div>
|
||||
<footer id="cw-footer" hidden>
|
||||
<input type="text" id="cw-input" placeholder="Write a message…" aria-label="Message">
|
||||
<button class="btn small primary" id="cw-send">Send</button>
|
||||
</footer>`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
const body = panel.querySelector('#cw-body');
|
||||
const unreadBadge = fab.querySelector('#cw-unread');
|
||||
|
||||
async function userName(id) {
|
||||
if (!userCache.has(id)) {
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/users/${id}/card`);
|
||||
userCache.set(id, res.card.name);
|
||||
} catch (e) { userCache.set(id, '…'); }
|
||||
}
|
||||
return userCache.get(id);
|
||||
}
|
||||
|
||||
async function refreshUnread() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/conversations');
|
||||
const total = res.conversations.reduce((a, c) => a + (c.unread || 0), 0);
|
||||
unreadBadge.textContent = total > 9 ? '9+' : String(total);
|
||||
unreadBadge.hidden = total === 0;
|
||||
return res.conversations;
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
async function showList() {
|
||||
current = null;
|
||||
panel.querySelector('#cw-back').hidden = true;
|
||||
panel.querySelector('#cw-footer').hidden = true;
|
||||
panel.querySelector('#cw-title').textContent = 'Messages';
|
||||
const convs = await refreshUnread();
|
||||
body.replaceChildren(...convs.map((c) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'cw-conv';
|
||||
b.innerHTML = `<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
|
||||
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}`;
|
||||
b.addEventListener('click', () => showThread(c));
|
||||
return b;
|
||||
}));
|
||||
if (!convs.length) {
|
||||
body.innerHTML = '<p class="muted" style="padding:10px">No conversations yet — start one from the Messages page.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMsg(m) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'cw-msg' + (m.senderId === meId ? ' own' : '');
|
||||
div.innerHTML = `<p class="cw-who">${esc(await userName(m.senderId))}</p><div>${m.body || ''}</div>` +
|
||||
((m.attachments || []).length ? `<p class="muted" style="margin:4px 0 0;font-size:0.75rem">📎 ${m.attachments.length} attachment(s)</p>` : '');
|
||||
return div;
|
||||
}
|
||||
|
||||
async function showThread(c) {
|
||||
current = c;
|
||||
panel.querySelector('#cw-back').hidden = false;
|
||||
panel.querySelector('#cw-footer').hidden = false;
|
||||
panel.querySelector('#cw-title').textContent = c.title;
|
||||
body.replaceChildren();
|
||||
const res = await api('GET', `/api/v1/conversations/${c.id}/messages?limit=30`);
|
||||
for (const m of res.messages) body.appendChild(await renderMsg(m));
|
||||
body.scrollTop = body.scrollHeight;
|
||||
api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {});
|
||||
refreshUnread();
|
||||
panel.querySelector('#cw-input').focus();
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const input = panel.querySelector('#cw-input');
|
||||
const text = input.value.trim();
|
||||
if (!text || !current) return;
|
||||
input.value = '';
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
|
||||
body: '<p>' + mentionHTML(text, input) + '</p>',
|
||||
});
|
||||
} catch (e) { input.value = text; }
|
||||
}
|
||||
panel.querySelector('#cw-send').addEventListener('click', send);
|
||||
const cwInput = panel.querySelector('#cw-input');
|
||||
cwInput.addEventListener('keydown', (e) => {
|
||||
if (cwInput.dataset.mentionActive === '1') return; // mention picker owns Enter
|
||||
if (e.key === 'Enter') { e.preventDefault(); send(); }
|
||||
});
|
||||
attachMentions(cwInput);
|
||||
|
||||
fab.addEventListener('click', async () => {
|
||||
open = !open;
|
||||
panel.hidden = !open;
|
||||
if (open) {
|
||||
if (!meId) {
|
||||
try { meId = (await api('GET', '/api/v1/auth/me')).user.id; } catch (e) { /* ignore */ }
|
||||
}
|
||||
showList();
|
||||
}
|
||||
});
|
||||
panel.querySelector('#cw-close').addEventListener('click', () => {
|
||||
open = false;
|
||||
panel.hidden = true;
|
||||
});
|
||||
panel.querySelector('#cw-back').addEventListener('click', showList);
|
||||
|
||||
subscribe('chat', async (event, data) => {
|
||||
if (event !== 'message' || !data) return;
|
||||
if (open && current && data.conversationId === current.id) {
|
||||
body.appendChild(await renderMsg(data));
|
||||
body.scrollTop = body.scrollHeight;
|
||||
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
||||
} else if (open && !current) {
|
||||
showList();
|
||||
} else {
|
||||
refreshUnread();
|
||||
}
|
||||
});
|
||||
onPollFallback(refreshUnread);
|
||||
refreshUnread();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// @-mention autocomplete. Attaches to a <textarea>/<input> or a
|
||||
// contenteditable element: typing "@" + letters shows a user picker; choosing
|
||||
// one inserts "@Name ". While the menu is open the host element has
|
||||
// data-mention-active="1" so the host's own Enter handler can defer to us.
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
const TOKEN_RE = /(?:^|\s)@([\w.\-]{0,30})$/;
|
||||
|
||||
export function attachMentions(el) {
|
||||
const isCE = el.isContentEditable;
|
||||
// drop any stale menu left over from a previous attach on the same field
|
||||
// (task/comment views recreate their composer on every re-render)
|
||||
if (el.id) {
|
||||
document.querySelectorAll(`.mention-menu[data-mention-for="${el.id}"]`).forEach((m) => m.remove());
|
||||
}
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'mention-menu';
|
||||
menu.hidden = true;
|
||||
if (el.id) menu.dataset.mentionFor = el.id;
|
||||
document.body.appendChild(menu);
|
||||
|
||||
let items = [];
|
||||
let active = 0;
|
||||
let seq = 0;
|
||||
el._mentions = el._mentions || new Map(); // display name -> user id
|
||||
|
||||
function close() {
|
||||
menu.hidden = true;
|
||||
items = [];
|
||||
delete el.dataset.mentionActive;
|
||||
}
|
||||
|
||||
function context() {
|
||||
if (isCE) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return null;
|
||||
const range = sel.getRangeAt(0);
|
||||
const node = range.startContainer;
|
||||
if (node.nodeType !== 3) return null;
|
||||
const before = node.textContent.slice(0, range.startOffset);
|
||||
const m = before.match(TOKEN_RE);
|
||||
if (!m) return null;
|
||||
return { query: m[1], node, end: range.startOffset, start: range.startOffset - m[1].length - 1 };
|
||||
}
|
||||
const pos = el.selectionStart;
|
||||
const m = el.value.slice(0, pos).match(TOKEN_RE);
|
||||
if (!m) return null;
|
||||
return { query: m[1], end: pos, start: pos - m[1].length - 1 };
|
||||
}
|
||||
|
||||
async function update() {
|
||||
const ctx = context();
|
||||
if (!ctx) { close(); return; }
|
||||
const mySeq = ++seq;
|
||||
let users;
|
||||
try { users = (await api('GET', `/api/v1/users?q=${encodeURIComponent(ctx.query)}`)).users; }
|
||||
catch (e) { return; }
|
||||
if (mySeq !== seq) return; // a newer keystroke superseded this fetch
|
||||
const ctx2 = context();
|
||||
if (!ctx2) { close(); return; }
|
||||
items = users.slice(0, 6);
|
||||
if (!items.length) { close(); return; }
|
||||
active = 0;
|
||||
render(ctx2);
|
||||
}
|
||||
|
||||
function render(ctx) {
|
||||
menu.replaceChildren(...items.map((u, i) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'mention-row' + (i === active ? ' active' : '');
|
||||
row.innerHTML = `<strong>${escapeHtml(u.name)}</strong> <span class="muted">${escapeHtml(u.email)}</span>`;
|
||||
row.addEventListener('mousedown', (e) => { e.preventDefault(); pick(u, ctx); });
|
||||
return row;
|
||||
}));
|
||||
position();
|
||||
menu.hidden = false;
|
||||
el.dataset.mentionActive = '1';
|
||||
}
|
||||
|
||||
function position() {
|
||||
const rect = el.getBoundingClientRect();
|
||||
menu.style.left = `${rect.left + window.scrollX}px`;
|
||||
menu.style.top = `${rect.bottom + window.scrollY + 2}px`;
|
||||
menu.style.minWidth = `${Math.min(Math.max(rect.width, 220), 360)}px`;
|
||||
}
|
||||
|
||||
function highlight() {
|
||||
[...menu.children].forEach((c, i) => c.classList.toggle('active', i === active));
|
||||
}
|
||||
|
||||
function pick(u, ctx) {
|
||||
el._mentions.set(u.name, u.id);
|
||||
if (isCE) {
|
||||
// insert an atomic mention chip + a trailing space in the text node
|
||||
const node = ctx.node;
|
||||
const full = node.textContent;
|
||||
const after = full.slice(ctx.end);
|
||||
node.textContent = full.slice(0, ctx.start);
|
||||
const span = document.createElement('span');
|
||||
span.className = 'mention';
|
||||
span.dataset.userCard = u.id;
|
||||
span.contentEditable = 'false';
|
||||
span.textContent = '@' + u.name;
|
||||
// the chip is an atomic, non-editable element, so the trailing space in
|
||||
// this separate text node stays put as the user keeps typing
|
||||
const tail = document.createTextNode(' ' + after);
|
||||
const parent = node.parentNode;
|
||||
const ref = node.nextSibling;
|
||||
parent.insertBefore(span, ref);
|
||||
parent.insertBefore(tail, ref);
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(tail, 1);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
const text = '@' + u.name + ' ';
|
||||
const v = el.value;
|
||||
el.value = v.slice(0, ctx.start) + text + v.slice(ctx.end);
|
||||
const caret = ctx.start + text.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
}
|
||||
close();
|
||||
el.focus();
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
el.addEventListener('input', update);
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (menu.hidden) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; highlight(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; highlight(); }
|
||||
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); pick(items[active], context()); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
});
|
||||
el.addEventListener('blur', () => setTimeout(close, 150));
|
||||
}
|
||||
|
||||
// mentionHTML turns the plain text of a textarea/input into safe HTML, wrapping
|
||||
// any recorded "@Name" run in a mention span carrying the user id. Names with
|
||||
// spaces are matched exactly (longest first) against what the picker inserted.
|
||||
export function mentionHTML(text, el) {
|
||||
const mentions = (el && el._mentions) || new Map();
|
||||
const names = [...mentions.keys()].sort((a, b) => b.length - a.length);
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (text[i] === '@') {
|
||||
const name = names.find((n) => text.startsWith('@' + n, i));
|
||||
if (name) {
|
||||
out += `<span class="mention" data-user-card="${escapeHtml(mentions.get(name))}">@${escapeHtml(name)}</span>`;
|
||||
i += 1 + name.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += escapeHtml(text[i]);
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.js';
|
||||
import { attachMentions } from '/static/js/mention.js';
|
||||
|
||||
const errorBox = document.getElementById('error');
|
||||
const convList = document.getElementById('conv-list');
|
||||
const msgHost = document.getElementById('chat-messages');
|
||||
const form = document.getElementById('chat-form');
|
||||
const composer = document.getElementById('chat-composer');
|
||||
|
||||
let meId = '';
|
||||
let conversations = [];
|
||||
let current = null;
|
||||
let oldestCursor = '';
|
||||
let pendingAttachments = [];
|
||||
const userCache = new Map();
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
async function userName(id) {
|
||||
if (!userCache.has(id)) {
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/users/${id}/card`);
|
||||
userCache.set(id, res.card.name);
|
||||
} catch (e) { userCache.set(id, '…'); }
|
||||
}
|
||||
return userCache.get(id);
|
||||
}
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/conversations');
|
||||
conversations = res.conversations;
|
||||
renderConvList();
|
||||
const want = new URLSearchParams(location.search).get('c');
|
||||
if (!current && want) {
|
||||
const c = conversations.find((x) => x.id === want);
|
||||
if (c) openConversation(c);
|
||||
}
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
function renderConvList() {
|
||||
convList.replaceChildren(...conversations.map((c) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = current && current.id === c.id ? 'active' : '';
|
||||
li.innerHTML = `
|
||||
<button type="button" class="conv-item">
|
||||
<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
|
||||
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}
|
||||
</button>`;
|
||||
li.querySelector('button').addEventListener('click', () => openConversation(c));
|
||||
return li;
|
||||
}));
|
||||
if (!conversations.length) {
|
||||
convList.innerHTML = '<li class="muted">No conversations yet.</li>';
|
||||
}
|
||||
}
|
||||
|
||||
async function openConversation(c) {
|
||||
current = c;
|
||||
oldestCursor = '';
|
||||
noMoreOlder = false;
|
||||
document.getElementById('chat-title').textContent = c.title;
|
||||
const mbtn = document.getElementById('chat-members');
|
||||
mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId);
|
||||
form.hidden = false;
|
||||
msgHost.replaceChildren();
|
||||
renderConvList();
|
||||
await loadMessages(false);
|
||||
await api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {});
|
||||
c.unread = 0;
|
||||
renderConvList();
|
||||
history.replaceState(null, '', '/messages?c=' + c.id);
|
||||
}
|
||||
|
||||
const MSG_PAGE = 40;
|
||||
async function loadMessages(prepend) {
|
||||
const res = await api('GET',
|
||||
`/api/v1/conversations/${current.id}/messages?limit=${MSG_PAGE}&cursor=${prepend ? oldestCursor : ''}`);
|
||||
const msgs = res.messages;
|
||||
if (prepend && msgs.length < MSG_PAGE) noMoreOlder = true;
|
||||
if (msgs.length) oldestCursor = msgs[0].id;
|
||||
const nodes = await Promise.all(msgs.map(renderMessage));
|
||||
if (prepend) {
|
||||
// preserve the scroll position so the view doesn't jump when older
|
||||
// messages are inserted above the current viewport
|
||||
const before = msgHost.scrollHeight;
|
||||
const top = msgHost.scrollTop;
|
||||
msgHost.prepend(...nodes);
|
||||
msgHost.scrollTop = top + (msgHost.scrollHeight - before);
|
||||
} else {
|
||||
msgHost.append(...nodes);
|
||||
msgHost.scrollTop = msgHost.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// load older history when the user scrolls near the top
|
||||
let loadingOlder = false;
|
||||
let noMoreOlder = false;
|
||||
msgHost.addEventListener('scroll', async () => {
|
||||
if (msgHost.scrollTop > 60 || loadingOlder || noMoreOlder || !current) return;
|
||||
loadingOlder = true;
|
||||
try { await loadMessages(true); } finally { loadingOlder = false; }
|
||||
});
|
||||
|
||||
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
|
||||
function ulidTime(id) {
|
||||
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
let ms = 0;
|
||||
for (const ch of String(id).slice(0, 10).toUpperCase()) ms = ms * 32 + A.indexOf(ch);
|
||||
return new Date(ms);
|
||||
}
|
||||
|
||||
async function renderMessage(m) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg' + (m.senderId === meId ? ' own' : '');
|
||||
const atts = (m.attachments || []).map((a) => a.isImage
|
||||
? `<img src="/files/${a.fileId}" alt="${esc(a.name)}" class="chat-img" data-lightbox loading="lazy">`
|
||||
: `<a class="btn small" href="/files/${a.fileId}">📄 ${esc(a.name)}</a>`).join(' ');
|
||||
div.innerHTML = `
|
||||
<p class="muted" style="margin:0">${esc(await userName(m.senderId))}
|
||||
· ${ulidTime(m.id).toLocaleString()}</p>
|
||||
<div>${m.body || ''}</div>
|
||||
${atts ? `<div class="mt">${atts}</div>` : ''}`;
|
||||
div.querySelectorAll('[data-lightbox]').forEach((img) => {
|
||||
img.addEventListener('click', () => {
|
||||
document.getElementById('lightbox-img').src = img.src;
|
||||
document.getElementById('lightbox').showModal();
|
||||
});
|
||||
});
|
||||
return div;
|
||||
}
|
||||
|
||||
document.getElementById('lightbox').addEventListener('click', () => {
|
||||
document.getElementById('lightbox').close();
|
||||
});
|
||||
|
||||
// ---- composer ----
|
||||
document.querySelectorAll('[data-cmd]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
composer.focus();
|
||||
document.execCommand(btn.dataset.cmd, false, null);
|
||||
});
|
||||
});
|
||||
|
||||
composer.addEventListener('keydown', (e) => {
|
||||
if (composer.dataset.mentionActive === '1') return; // mention picker owns Enter
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
attachMentions(composer);
|
||||
|
||||
let typingTimer = null;
|
||||
composer.addEventListener('input', () => {
|
||||
if (!current) return;
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(() => {
|
||||
wsSend('chat', 'typing', { conversationId: current.id });
|
||||
}, 250);
|
||||
});
|
||||
|
||||
async function uploadFiles(fileList) {
|
||||
for (const file of fileList) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await api('POST', '/api/v1/files', fd);
|
||||
pendingAttachments.push(res);
|
||||
renderPending();
|
||||
} catch (e) { toast(`Upload failed: ${e.message}`, 'err'); }
|
||||
}
|
||||
}
|
||||
|
||||
function renderPending() {
|
||||
const host = document.getElementById('chat-attachments');
|
||||
host.replaceChildren(...pendingAttachments.map((a, i) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge';
|
||||
chip.innerHTML = `${a.isImage ? '🖼' : '📄'} ${esc(a.name)} <button type="button" class="btn small ghost" aria-label="Remove attachment">×</button>`;
|
||||
chip.querySelector('button').addEventListener('click', () => {
|
||||
pendingAttachments.splice(i, 1);
|
||||
renderPending();
|
||||
});
|
||||
return chip;
|
||||
}));
|
||||
}
|
||||
|
||||
document.getElementById('chat-file').addEventListener('change', (e) => {
|
||||
uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
});
|
||||
composer.addEventListener('dragover', (e) => e.preventDefault());
|
||||
composer.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!current) return;
|
||||
const body = composer.innerHTML;
|
||||
if (!composer.textContent.trim() && !pendingAttachments.length) return;
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
|
||||
body, attachments: pendingAttachments.map((a) => a.fileId),
|
||||
});
|
||||
composer.innerHTML = '';
|
||||
pendingAttachments = [];
|
||||
renderPending();
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// ---- live events ----
|
||||
const typingNames = new Map();
|
||||
subscribe('chat', async (event, data) => {
|
||||
if (event === 'message' && data) {
|
||||
if (current && data.conversationId === current.id) {
|
||||
const nearBottom = msgHost.scrollHeight - msgHost.scrollTop - msgHost.clientHeight < 120;
|
||||
msgHost.appendChild(await renderMessage(data));
|
||||
if (nearBottom) msgHost.scrollTop = msgHost.scrollHeight;
|
||||
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
||||
} else {
|
||||
loadConversations();
|
||||
}
|
||||
}
|
||||
if (event === 'typing' && data && current && data.conversationId === current.id) {
|
||||
const name = await userName(data.userId);
|
||||
typingNames.set(data.userId, Date.now());
|
||||
document.getElementById('typing-indicator').textContent = `${name} is typing…`;
|
||||
setTimeout(() => {
|
||||
if (Date.now() - (typingNames.get(data.userId) || 0) >= 2900) {
|
||||
document.getElementById('typing-indicator').textContent = '';
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
onPollFallback(() => {
|
||||
loadConversations();
|
||||
if (current) {
|
||||
msgHost.replaceChildren();
|
||||
oldestCursor = '';
|
||||
loadMessages(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- new conversation dialog ----
|
||||
const dlg = document.getElementById('newconv-dialog');
|
||||
const selected = new Map();
|
||||
document.getElementById('conv-new').addEventListener('click', () => {
|
||||
selected.clear();
|
||||
renderSelected();
|
||||
document.getElementById('nc-search').value = '';
|
||||
dlg.showModal();
|
||||
searchPeople(); // pre-fill the fixed-height list so the dialog never jumps
|
||||
});
|
||||
document.getElementById('nc-cancel').addEventListener('click', () => dlg.close());
|
||||
document.getElementById('nc-kind').addEventListener('change', (e) => {
|
||||
document.getElementById('nc-title-wrap').hidden = e.target.value === 'dm';
|
||||
document.getElementById('nc-customer-wrap').hidden = e.target.value !== 'project';
|
||||
});
|
||||
|
||||
let searchTimer = null;
|
||||
async function searchPeople() {
|
||||
const q = document.getElementById('nc-search').value.trim();
|
||||
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
|
||||
const host = document.getElementById('nc-results');
|
||||
const rows = res.users.filter((u) => u.id !== meId && !selected.has(u.id)).map((u) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'nc-row';
|
||||
b.setAttribute('role', 'option');
|
||||
b.textContent = `${u.name} — ${u.email}`;
|
||||
b.addEventListener('click', () => {
|
||||
selected.set(u.id, u);
|
||||
renderSelected();
|
||||
searchPeople(); // refresh list so picked people disappear
|
||||
});
|
||||
return b;
|
||||
});
|
||||
host.replaceChildren(...rows);
|
||||
if (!rows.length) {
|
||||
host.innerHTML = '<p class="nc-empty">No matches — try a name or email.</p>';
|
||||
}
|
||||
}
|
||||
document.getElementById('nc-search').addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(searchPeople, 250);
|
||||
});
|
||||
|
||||
function renderSelected() {
|
||||
const host = document.getElementById('nc-selected');
|
||||
host.replaceChildren(...[...selected.values()].map((u) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge';
|
||||
chip.textContent = u.name + ' ×';
|
||||
chip.style.cursor = 'pointer';
|
||||
chip.addEventListener('click', () => { selected.delete(u.id); renderSelected(); });
|
||||
return chip;
|
||||
}));
|
||||
}
|
||||
|
||||
document.getElementById('newconv-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const kind = document.getElementById('nc-kind').value;
|
||||
const res = await api('POST', '/api/v1/conversations', {
|
||||
kind,
|
||||
title: document.getElementById('nc-title').value.trim(),
|
||||
customerId: document.getElementById('nc-customer').value.trim(),
|
||||
participantIds: [...selected.keys()],
|
||||
});
|
||||
dlg.close();
|
||||
await loadConversations();
|
||||
const conv = conversations.find((c) => c.id === res.conversation.id);
|
||||
if (conv) openConversation(conv);
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// ---- message search ----
|
||||
const convSearch = document.getElementById('conv-search');
|
||||
let msgSearchTimer = null;
|
||||
async function runMessageSearch(q) {
|
||||
const list = document.getElementById('conv-list');
|
||||
const results = document.getElementById('search-results');
|
||||
if (q.length < 2) { results.hidden = true; results.replaceChildren(); list.hidden = false; return; }
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/messages/search?q=${encodeURIComponent(q)}`);
|
||||
list.hidden = true; results.hidden = false;
|
||||
results.replaceChildren(...res.results.map((r) => {
|
||||
const li = document.createElement('li');
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'conv-item';
|
||||
b.style.cssText = 'flex-direction:column;align-items:flex-start;gap:2px';
|
||||
b.innerHTML = `<span>${esc(r.conversationTitle)}</span>
|
||||
<span class="muted" style="font-size:0.82rem">${esc(r.snippet)}</span>`;
|
||||
b.addEventListener('click', () => {
|
||||
const c = conversations.find((x) => x.id === r.conversationId);
|
||||
if (c) {
|
||||
convSearch.value = '';
|
||||
results.hidden = true; list.hidden = false;
|
||||
openConversation(c);
|
||||
}
|
||||
});
|
||||
li.appendChild(b);
|
||||
return li;
|
||||
}));
|
||||
if (!res.results.length) {
|
||||
results.innerHTML = '<li class="muted" style="padding:10px">No matching messages.</li>';
|
||||
}
|
||||
} catch (e) { /* ignore transient search errors */ }
|
||||
}
|
||||
convSearch.addEventListener('input', () => {
|
||||
clearTimeout(msgSearchTimer);
|
||||
const q = convSearch.value.trim();
|
||||
msgSearchTimer = setTimeout(() => runMessageSearch(q), 250);
|
||||
});
|
||||
|
||||
// ---- manage members (group creator only) ----
|
||||
const membersDlg = document.getElementById('members-dialog');
|
||||
let mmMemberIds = new Set();
|
||||
|
||||
async function renderMembers() {
|
||||
const res = await api('GET', `/api/v1/conversations/${current.id}/members`);
|
||||
mmMemberIds = new Set(res.members.map((u) => u.id));
|
||||
const host = document.getElementById('mm-list');
|
||||
host.replaceChildren(...res.members.map((u) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'conv-item';
|
||||
li.style.justifyContent = 'space-between';
|
||||
li.innerHTML = `<span>${esc(u.name)} ${u.isCreator ? '<span class="badge accent">creator</span>' : ''}
|
||||
<br><span class="muted">${esc(u.email)}</span></span>`;
|
||||
if (res.canManage && !u.isCreator) {
|
||||
const rm = document.createElement('button');
|
||||
rm.className = 'btn small';
|
||||
rm.textContent = 'Remove';
|
||||
rm.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('DELETE', `/api/v1/conversations/${current.id}/members/${u.id}`);
|
||||
await renderMembers();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
li.appendChild(rm);
|
||||
}
|
||||
return li;
|
||||
}));
|
||||
document.getElementById('mm-add-wrap').hidden = !res.canManage;
|
||||
}
|
||||
|
||||
let mmTimer = null;
|
||||
async function mmSearch() {
|
||||
const q = document.getElementById('mm-search').value.trim();
|
||||
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
|
||||
const host = document.getElementById('mm-results');
|
||||
const rows = res.users.filter((u) => !mmMemberIds.has(u.id)).map((u) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'nc-row';
|
||||
b.textContent = `${u.name} — ${u.email}`;
|
||||
b.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/members`, { userId: u.id });
|
||||
document.getElementById('mm-search').value = '';
|
||||
host.replaceChildren();
|
||||
await renderMembers();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
return b;
|
||||
});
|
||||
host.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
document.getElementById('chat-members').addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
document.getElementById('mm-search').value = '';
|
||||
document.getElementById('mm-results').replaceChildren();
|
||||
try { await renderMembers(); membersDlg.showModal(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
document.getElementById('mm-close').addEventListener('click', () => membersDlg.close());
|
||||
document.getElementById('mm-search').addEventListener('input', () => {
|
||||
clearTimeout(mmTimer);
|
||||
mmTimer = setTimeout(mmSearch, 250);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
meId = me.user.id;
|
||||
} catch (e) { /* page guard handles */ }
|
||||
loadConversations();
|
||||
})();
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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();
|
||||
})();
|
||||
@@ -16,9 +16,12 @@ const themeBtn = document.getElementById('nav-theme');
|
||||
if (themeBtn) {
|
||||
themeBtn.addEventListener('click', async () => {
|
||||
const el = document.documentElement;
|
||||
const next = el.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
const themes = (el.dataset.themes || 'light,dark').split(',');
|
||||
const cur = themes.indexOf(el.dataset.theme);
|
||||
const next = themes[(cur + 1) % themes.length];
|
||||
el.dataset.theme = next;
|
||||
try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ }
|
||||
toast('Theme: ' + next);
|
||||
if (document.body.dataset.loggedIn === '1') {
|
||||
try {
|
||||
await api('PATCH', '/api/v1/profile', { settings: { theme: next } });
|
||||
|
||||
@@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => {
|
||||
links,
|
||||
},
|
||||
extra,
|
||||
settings: { theme: document.getElementById('p-theme').value },
|
||||
settings: {
|
||||
theme: document.getElementById('p-theme').value,
|
||||
navLayout: document.getElementById('p-nav').value,
|
||||
},
|
||||
});
|
||||
version = res.user.version;
|
||||
const theme = res.user.settings.theme;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
|
||||
document.body.dataset.nav = res.user.settings.navLayout || 'side';
|
||||
okBox.textContent = 'Profile saved.';
|
||||
} catch (err) {
|
||||
if (err.status === 409) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Public profile view (/users/{id}): full bio, contact, links and custom
|
||||
// details from the user's profile card.
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
const root = document.getElementById('profile-root');
|
||||
const errorBox = document.getElementById('error');
|
||||
const uid = root.dataset.userId;
|
||||
|
||||
const HIDDEN_EXTRA = new Set(['ticketingIdentities', 'savedBoardFilters']);
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
// only allow safe link schemes; bare domains get https://, others are blocked
|
||||
export function safeUrl(u) {
|
||||
const s = String(u ?? '').trim();
|
||||
if (/^(https?:\/\/|mailto:)/i.test(s)) return s;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#';
|
||||
return s ? 'https://' + s : '#';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/users/${uid}/card`);
|
||||
const c = res.card;
|
||||
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles && c.roles[r]);
|
||||
const links = ((c.contact && c.contact.links) || []).filter(Boolean);
|
||||
const extra = Object.entries(c.extra || {})
|
||||
.filter(([k, v]) => !HIDDEN_EXTRA.has(k) && v !== null && typeof v !== 'object');
|
||||
const hasContact = (c.contact && (c.contact.phone || c.contact.location)) || links.length;
|
||||
root.innerHTML = `
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:16px;align-items:center">
|
||||
${c.avatarFileId
|
||||
? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
|
||||
: `<span class="avatar large">${esc((c.name || '?').slice(0, 1).toUpperCase())}</span>`}
|
||||
<div>
|
||||
<h1 style="margin:0">${esc(c.name)}</h1>
|
||||
<p style="margin:4px 0">${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
${c.bio ? `<h2 class="mt">Bio</h2><p style="white-space:pre-wrap">${esc(c.bio)}</p>` : ''}
|
||||
${hasContact ? '<h2 class="mt">Contact</h2>' : ''}
|
||||
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}
|
||||
${c.contact && c.contact.phone ? `<p class="muted">📞 ${esc(c.contact.phone)}</p>` : ''}
|
||||
${links.length ? `<ul>${links.map((l) =>
|
||||
`<li><a href="${esc(safeUrl(l))}" target="_blank" rel="noopener">${esc(l)}</a></li>`).join('')}</ul>` : ''}
|
||||
${extra.length ? `<h2 class="mt">Details</h2>${extra.map(([k, v]) =>
|
||||
`<p class="muted"><strong>${esc(k)}:</strong> ${esc(String(v))}</p>`).join('')}` : ''}
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
errorBox.textContent = 'Could not load this profile.';
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -26,7 +26,7 @@ async function load() {
|
||||
return card;
|
||||
}));
|
||||
if (!res.tasks.length) {
|
||||
host.innerHTML = '<p class="muted">Nothing waiting for review. 🎉</p>';
|
||||
host.innerHTML = '<p class="muted">Nothing waiting for review.</p>';
|
||||
}
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Global keyboard shortcuts (§10): g b → board, g m → messages, / → search.
|
||||
// Plus profile hover cards (§11.13) for any [data-user-card] element.
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
let pendingG = false;
|
||||
let gTimer = null;
|
||||
|
||||
function typingTarget(e) {
|
||||
const t = e.target;
|
||||
return t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (typingTarget(e) || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.key === '/') {
|
||||
const search = document.querySelector('input[type=search]');
|
||||
if (search) { e.preventDefault(); search.focus(); }
|
||||
return;
|
||||
}
|
||||
if (pendingG) {
|
||||
pendingG = false;
|
||||
clearTimeout(gTimer);
|
||||
const map = { b: '/board', m: '/messages', h: '/', t: '/my-tasks', a: '/consultant/board' };
|
||||
if (map[e.key]) { e.preventDefault(); window.location.href = map[e.key]; }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'g') {
|
||||
pendingG = true;
|
||||
gTimer = setTimeout(() => { pendingG = false; }, 1200);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- hover cards ----
|
||||
let cardEl = null;
|
||||
let hideTimer = null;
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
// allow only safe link schemes (block javascript:/data: etc.)
|
||||
const safeUrl = (u) => {
|
||||
const s = String(u ?? '').trim();
|
||||
if (/^(https?:\/\/|mailto:)/i.test(s)) return s;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#';
|
||||
return s ? 'https://' + s : '#';
|
||||
};
|
||||
|
||||
function hideCard() {
|
||||
if (cardEl) { cardEl.remove(); cardEl = null; }
|
||||
}
|
||||
|
||||
async function showCard(target, userId) {
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/users/${userId}/card`);
|
||||
const c = res.card;
|
||||
hideCard();
|
||||
cardEl = document.createElement('div');
|
||||
cardEl.className = 'hovercard card';
|
||||
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]);
|
||||
const links = ((c.contact && c.contact.links) || []).filter(Boolean);
|
||||
cardEl.innerHTML = `
|
||||
<div class="spread">
|
||||
${c.avatarFileId ? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
|
||||
: `<span class="avatar large">${esc(c.name.slice(0, 1).toUpperCase())}</span>`}
|
||||
<div>
|
||||
<strong>${esc(c.name)}</strong><br>
|
||||
${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}
|
||||
</div>
|
||||
</div>
|
||||
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 220))}${c.bio.length > 220 ? '…' : ''}</p>` : ''}
|
||||
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}
|
||||
${c.contact && c.contact.phone ? `<p class="muted">📞 ${esc(c.contact.phone)}</p>` : ''}
|
||||
${links.length ? `<p class="muted">${links.slice(0, 3).map((l) =>
|
||||
`<a href="${esc(safeUrl(l))}" target="_blank" rel="noopener">🔗 ${esc(l)}</a>`).join('<br>')}</p>` : ''}
|
||||
<a class="btn small mt" href="/users/${encodeURIComponent(userId)}">See full profile →</a>`;
|
||||
document.body.appendChild(cardEl);
|
||||
const rect = target.getBoundingClientRect();
|
||||
cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px';
|
||||
cardEl.style.top = (rect.bottom + window.scrollY + 6) + 'px';
|
||||
cardEl.addEventListener('mouseenter', () => clearTimeout(hideTimer));
|
||||
cardEl.addEventListener('mouseleave', () => { hideTimer = setTimeout(hideCard, 200); });
|
||||
} catch (e) { /* card is best-effort */ }
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const target = e.target.closest('[data-user-card]');
|
||||
if (!target) return;
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => showCard(target, target.dataset.userCard), 350);
|
||||
});
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
if (e.target.closest('[data-user-card]')) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(hideCard, 250);
|
||||
}
|
||||
});
|
||||
// click a mention (or any user-card target) to open its card immediately
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target.closest('[data-user-card]');
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
clearTimeout(hideTimer);
|
||||
showCard(target, target.dataset.userCard);
|
||||
});
|
||||
+18
-3
@@ -1,5 +1,6 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe } from '/static/js/ws.js';
|
||||
import { attachMentions, mentionHTML } from '/static/js/mention.js';
|
||||
|
||||
const root = document.getElementById('task-root');
|
||||
const errorBox = document.getElementById('error');
|
||||
@@ -57,7 +58,7 @@ async function render() {
|
||||
const comments = await Promise.all((t.comments || []).map(async (c) => `
|
||||
<div class="card" style="padding:12px">
|
||||
<p class="muted" style="margin:0 0 8px">${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}</p>
|
||||
<div>${c.body}</div>
|
||||
<div class="comment-body">${c.body}</div>
|
||||
</div>`));
|
||||
|
||||
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
|
||||
@@ -146,6 +147,18 @@ function renderActions() {
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
};
|
||||
|
||||
// link to the upstream ticket (same affordance as the atomization board)
|
||||
if (t.external && t.external.url) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'btn';
|
||||
a.style.marginRight = '8px';
|
||||
a.href = t.external.url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = 'Source ↗';
|
||||
host.appendChild(a);
|
||||
}
|
||||
|
||||
if (root.dataset.isDeveloper === '1') {
|
||||
if (t.status === 'published' && !myClaim()) add('Request assignment', post('claim', { note: '' }), true);
|
||||
if (myClaim() && ['published', 'claim_requested'].includes(t.status)) add('Withdraw request', post('claim/withdraw'));
|
||||
@@ -182,12 +195,14 @@ function wireHandlers() {
|
||||
});
|
||||
document.getElementById('comment-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = document.getElementById('comment-body').value;
|
||||
const field = document.getElementById('comment-body');
|
||||
const body = mentionHTML(field.value, field).replace(/\n/g, '<br>');
|
||||
try {
|
||||
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + esc(body).replace(/\n/g, '<br>') + '</p>' });
|
||||
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + body + '</p>' });
|
||||
load();
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
attachMentions(document.getElementById('comment-body'));
|
||||
const timeForm = document.getElementById('time-form');
|
||||
if (timeForm) {
|
||||
timeForm.addEventListener('submit', async (e) => {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
var el = document.documentElement;
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
|
||||
var THEMES = ['light', 'dark', 'neon', 'terminal', 'blueprint', 'sunset'];
|
||||
var theme = saved || el.dataset.defaultTheme || 'light';
|
||||
if (theme !== 'light' && theme !== 'dark') theme = 'light';
|
||||
if (THEMES.indexOf(theme) === -1) theme = 'light';
|
||||
el.dataset.theme = theme;
|
||||
el.dataset.themes = THEMES.join(',');
|
||||
})();
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<option value="jira">Jira Cloud</option>
|
||||
<option value="azure_devops">Azure DevOps</option>
|
||||
<option value="youtrack">YouTrack</option>
|
||||
<option value="wekan">WeKan</option>
|
||||
<option value="demo">Demo (offline)</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -58,6 +59,12 @@
|
||||
<legend>YouTrack credentials</legend>
|
||||
<div class="field"><label for="c-yt-token">Permanent token</label><input type="password" id="c-yt-token" autocomplete="off"></div>
|
||||
</fieldset>
|
||||
<fieldset id="creds-wekan" class="creds" hidden>
|
||||
<legend>WeKan credentials</legend>
|
||||
<p class="hint">Project key above = the WeKan board id; the connector imports cards assigned to each consultant's WeKan username.</p>
|
||||
<div class="field"><label for="c-wekan-user">Username</label><input type="text" id="c-wekan-user" autocomplete="off"></div>
|
||||
<div class="field"><label for="c-wekan-pass">Password</label><input type="password" id="c-wekan-pass" autocomplete="off"></div>
|
||||
</fieldset>
|
||||
<fieldset id="creds-demo" class="creds" hidden>
|
||||
<legend>Demo</legend>
|
||||
<p class="hint">No credentials required — tickets are fabricated locally.</p>
|
||||
@@ -77,6 +84,11 @@
|
||||
<select id="c-consultants" multiple size="4"></select>
|
||||
<p class="hint">Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.</p>
|
||||
</div>
|
||||
<div class="field" id="c-identities-wrap" hidden>
|
||||
<label>Ticketing account mapping</label>
|
||||
<p class="hint">Username in this project's ticketing system for each assigned consultant. Overrides the consultant's global identity. Leave blank to use their global one.</p>
|
||||
<div id="c-identities"></div>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<span id="c-test-result" class="hint" aria-live="polite"></span>
|
||||
<span>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "content"}}
|
||||
<h1>Archive</h1>
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<input type="search" id="archive-search" class="mt"
|
||||
placeholder="Search archived tasks…" aria-label="Search archived tasks"
|
||||
style="max-width:440px; width:100%">
|
||||
<p class="muted" style="margin-top:6px">Archived tasks you can access.</p>
|
||||
<div class="grid mt" id="archive-list"></div>
|
||||
{{end}}
|
||||
@@ -2,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,10 +22,8 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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,18 +3,20 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · Bounty Board</title>
|
||||
<title>{{.Title}} · {{.Brand}}</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body {{if .User}}data-logged-in="1"{{end}}>
|
||||
<body {{if .User}}data-logged-in="1"{{end}} data-nav="{{if .User}}{{.NavLayout}}{{else}}top{{end}}">
|
||||
<nav class="topnav">
|
||||
<a class="brand" href="/">Bounty Board</a>
|
||||
<a class="brand" href="/">{{.Brand}}</a>
|
||||
<div class="links">
|
||||
{{if .User}}
|
||||
{{if .User.Roles.Developer}}
|
||||
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
|
||||
<a href="/my-tasks" {{if eq .Active "my-tasks"}}aria-current="page"{{end}}>My Tasks</a>
|
||||
{{else if .User.Roles.Consultant}}
|
||||
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
|
||||
{{end}}
|
||||
{{if .User.Roles.Consultant}}
|
||||
<a href="/consultant/board" {{if eq .Active "atomization"}}aria-current="page"{{end}}>Atomization</a>
|
||||
@@ -26,6 +28,7 @@
|
||||
{{end}}
|
||||
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a>
|
||||
<a href="/metrics" {{if eq .Active "metrics"}}aria-current="page"{{end}}>Metrics</a>
|
||||
<a href="/archive" {{if eq .Active "archive"}}aria-current="page"{{end}}>Archive</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="right">
|
||||
@@ -57,7 +60,9 @@
|
||||
</main>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<script type="module" src="/static/js/nav.js"></script>
|
||||
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>{{end}}
|
||||
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>
|
||||
<script type="module" src="/static/js/shortcuts.js"></script>
|
||||
<script type="module" src="/static/js/chat-widget.js"></script>{{end}}
|
||||
{{range .Scripts}}<script type="module" src="{{.}}"></script>
|
||||
{{end}}
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{{define "content"}}
|
||||
<header class="masthead">
|
||||
<p class="masthead-kicker">Consulting Work Exchange</p>
|
||||
<p class="masthead-title">◈ {{.Brand}}</p>
|
||||
<p class="masthead-rule">❦</p>
|
||||
</header>
|
||||
<div class="card">
|
||||
<h1>Log in</h1>
|
||||
<div class="error-box" id="error" role="alert">{{.Error}}</div>
|
||||
@@ -11,15 +16,13 @@
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<div class="login-actions">
|
||||
<button class="btn primary" type="submit">Log in</button>
|
||||
<a href="/register">Create an account</a>
|
||||
{{if .OIDCEnabled}}<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>{{end}}
|
||||
</div>
|
||||
</form>
|
||||
{{if .OIDCEnabled}}
|
||||
<div class="mt">
|
||||
<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="muted mt" style="margin-bottom:0">
|
||||
<a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a>
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{{define "content"}}
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<div class="chat-layout">
|
||||
<aside class="chat-sidebar card">
|
||||
<div class="spread">
|
||||
<h2>Messages</h2>
|
||||
<button class="btn small primary" id="conv-new">+ New</button>
|
||||
</div>
|
||||
<input type="search" id="conv-search" class="mt" placeholder="Search messages…" aria-label="Search messages">
|
||||
<ul id="conv-list" class="conv-list"></ul>
|
||||
<ul id="search-results" class="conv-list" hidden></ul>
|
||||
</aside>
|
||||
<section class="chat-main card">
|
||||
<div class="spread">
|
||||
<h2 id="chat-title">Select a conversation</h2>
|
||||
<span style="display:flex;align-items:center;gap:10px">
|
||||
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
||||
<button class="btn small" id="chat-members" type="button" hidden>Manage members</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
|
||||
<form id="chat-form" hidden>
|
||||
<div id="chat-attachments" class="chat-attachments"></div>
|
||||
<div class="chat-toolbar" role="toolbar" aria-label="Formatting">
|
||||
<button class="btn small" type="button" data-cmd="bold" aria-label="Bold"><b>B</b></button>
|
||||
<button class="btn small" type="button" data-cmd="italic" aria-label="Italic"><i>I</i></button>
|
||||
<button class="btn small" type="button" data-cmd="underline" aria-label="Underline"><u>U</u></button>
|
||||
<button class="btn small" type="button" data-cmd="strikeThrough" aria-label="Strikethrough"><s>S</s></button>
|
||||
<button class="btn small" type="button" data-cmd="insertUnorderedList" aria-label="Bulleted list">• list</button>
|
||||
<label class="btn small" style="cursor:pointer">📎<input type="file" id="chat-file" multiple hidden></label>
|
||||
</div>
|
||||
<div id="chat-composer" class="chat-composer" contenteditable="true" role="textbox"
|
||||
aria-multiline="true" aria-label="Message" data-placeholder="Write a message… (drag & drop files)"></div>
|
||||
<div class="spread mt">
|
||||
<span class="hint">Enter to send · Shift+Enter for newline</span>
|
||||
<button class="btn primary" type="submit">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog id="newconv-dialog">
|
||||
<form method="dialog" id="newconv-form" class="stack" style="min-width:420px">
|
||||
<h2>New conversation</h2>
|
||||
<div class="field">
|
||||
<label for="nc-kind">Kind</label>
|
||||
<select id="nc-kind">
|
||||
<option value="dm">Direct message</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="project">Project</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="nc-title-wrap" hidden>
|
||||
<label for="nc-title">Title</label>
|
||||
<input type="text" id="nc-title">
|
||||
</div>
|
||||
<div class="field" id="nc-customer-wrap" hidden>
|
||||
<label for="nc-customer">Customer id</label>
|
||||
<input type="text" id="nc-customer" placeholder="paste customer id">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc-search">Participants</label>
|
||||
<input type="search" id="nc-search" placeholder="Search people…">
|
||||
<div id="nc-results" role="listbox" aria-label="Search results"></div>
|
||||
<div id="nc-selected" class="mt"></div>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<button class="btn" type="button" id="nc-cancel">Cancel</button>
|
||||
<button class="btn primary" type="submit">Start conversation</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="members-dialog">
|
||||
<div class="stack" style="min-width:420px">
|
||||
<div class="spread">
|
||||
<h2 style="margin:0">Group members</h2>
|
||||
<button class="btn small" type="button" id="mm-close">Close</button>
|
||||
</div>
|
||||
<ul id="mm-list" class="conv-list"></ul>
|
||||
<div class="field" id="mm-add-wrap">
|
||||
<label for="mm-search">Add someone</label>
|
||||
<input type="search" id="mm-search" placeholder="Search people…">
|
||||
<div id="mm-results" role="listbox" aria-label="Search results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="lightbox" class="lightbox">
|
||||
<img id="lightbox-img" alt="">
|
||||
</dialog>
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
@@ -57,8 +57,19 @@
|
||||
<div class="field">
|
||||
<label for="p-theme">Theme</label>
|
||||
<select id="p-theme">
|
||||
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (beige)</option>
|
||||
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</option>
|
||||
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (Y2K paper)</option>
|
||||
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark (Y2K ink)</option>
|
||||
<option value="neon" {{if eq .User.Settings.Theme "neon"}}selected{{end}}>Neon (cyber gradient)</option>
|
||||
<option value="terminal" {{if eq .User.Settings.Theme "terminal"}}selected{{end}}>Terminal (green CRT)</option>
|
||||
<option value="blueprint" {{if eq .User.Settings.Theme "blueprint"}}selected{{end}}>Blueprint (graph paper)</option>
|
||||
<option value="sunset" {{if eq .User.Settings.Theme "sunset"}}selected{{end}}>Sunset (vaporwave)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p-nav">Navigation</label>
|
||||
<select id="p-nav">
|
||||
<option value="side" {{if ne .User.Settings.NavLayout "top"}}selected{{end}}>Sidebar (left)</option>
|
||||
<option value="top" {{if eq .User.Settings.NavLayout "top"}}selected{{end}}>Top bar</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{{define "content"}}
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<div id="profile-root" data-user-id="{{.Data.UserID}}"></div>
|
||||
{{end}}
|
||||
@@ -1,4 +1,9 @@
|
||||
{{define "content"}}
|
||||
<header class="masthead">
|
||||
<p class="masthead-kicker">Consulting Work Exchange</p>
|
||||
<p class="masthead-title">◈ {{.Brand}}</p>
|
||||
<p class="masthead-rule">❦</p>
|
||||
</header>
|
||||
<div class="card">
|
||||
<h1>Create account</h1>
|
||||
<p class="hint">Self-registration creates a developer account. Consultant and admin roles are granted by an administrator.</p>
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user