Compare commits
10 Commits
70a813edfa
...
05dc91410f
| Author | SHA1 | Date | |
|---|---|---|---|
| 05dc91410f | |||
| c59aea42ef | |||
| b5ebbfb748 | |||
| 7de1086725 | |||
| f3897907a3 | |||
| 6265ffa894 | |||
| c69c028028 | |||
| d698f70c4f | |||
| b1c9a42810 | |||
| 7d3140334e |
@@ -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=
|
||||
|
||||
@@ -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 \
|
||||
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}
|
||||
+3
-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 {
|
||||
@@ -107,7 +109,7 @@ func run() error {
|
||||
|
||||
// Background job queue + atomization handlers.
|
||||
runner := jobs.NewRunner(st, log, reg, 4)
|
||||
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL,
|
||||
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppInternalURL,
|
||||
[]byte(cfg.SessionSecret), srv.Publish)
|
||||
runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide)
|
||||
runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend)
|
||||
|
||||
+57
-1
@@ -27,8 +27,16 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
MONGO_URI: mongodb://mongo:27017
|
||||
# in-network address handed to the external services for callbacks
|
||||
# and signed attachment URLs (§5.2 example: http://app:8787/…)
|
||||
APP_INTERNAL_URL: http://app:8787
|
||||
# NOTE: published on all interfaces to match this host's pattern (gitea,
|
||||
# outline, wekan are exposed the same way; docker-published ports bypass
|
||||
# UFW). The spec-default loopback-only binding is the commented line —
|
||||
# restore it once the app sits behind the reverse proxy exclusively.
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-8787}:8787"
|
||||
- "${APP_PORT:-8787}:8787"
|
||||
# - "127.0.0.1:${APP_PORT:-8787}:8787"
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
@@ -39,5 +47,53 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# --- placeholder external services (docker compose --profile mocks up) ---
|
||||
# Two fully independent services: separate builds, ports, tokens (§5).
|
||||
atomizer-mock:
|
||||
build: ./services/atomizer
|
||||
profiles: ["mocks"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: "8090"
|
||||
ATOMIZER_TOKEN: ${ATOMIZER_TOKEN}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
ANTHROPIC_OPENAI_BASE_URL: ${ANTHROPIC_OPENAI_BASE_URL:-https://api.anthropic.com/v1}
|
||||
ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-6}
|
||||
LLM_API_STYLE: ${LLM_API_STYLE:-openai}
|
||||
ports:
|
||||
- "127.0.0.1:8090:8090"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
work-performer:
|
||||
build: ./services/work-performer
|
||||
profiles: ["mocks"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: "8091"
|
||||
WORK_PERFORMER_TOKEN: ${WORK_PERFORMER_TOKEN}
|
||||
PUBLIC_BASE_URL: http://work-performer:8091
|
||||
# host Claude Code config/secrets (§9.2); mounted into the node user's
|
||||
# home because claude refuses --dangerously-skip-permissions as root
|
||||
volumes:
|
||||
- ${HOME}/.claude:/home/node/.claude
|
||||
- ${HOME}/.claude.json:/home/node/.claude.json
|
||||
- work-data:/work
|
||||
ports:
|
||||
- "127.0.0.1:8091:8091"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8091/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
work-data:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -37,6 +37,10 @@ func (o OIDC) Enabled() bool {
|
||||
|
||||
type Config struct {
|
||||
AppBaseURL string
|
||||
// AppInternalURL is how the external services reach the app from inside
|
||||
// the compose network (callback URLs, signed attachment URLs). Defaults
|
||||
// to AppBaseURL.
|
||||
AppInternalURL string
|
||||
TrustedProxyCIDRs []netip.Prefix
|
||||
AppPort int
|
||||
MongoURI string
|
||||
@@ -132,6 +136,7 @@ func Load() (*Config, error) {
|
||||
CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
|
||||
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"),
|
||||
}
|
||||
c.AppInternalURL = strings.TrimRight(getenv("APP_INTERNAL_URL", c.AppBaseURL), "/")
|
||||
c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
|
||||
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,6 +55,7 @@ type NotificationPrefs struct {
|
||||
type UserSettings struct {
|
||||
Theme string `bson:"theme" json:"theme"`
|
||||
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
||||
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
|
||||
@@ -119,7 +119,7 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
if req.Type == nil || !req.Type.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack or demo")
|
||||
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack, wekan or demo")
|
||||
return
|
||||
}
|
||||
settings, err := s.store.GetSettings(r.Context())
|
||||
|
||||
@@ -24,6 +24,79 @@ func (s *Server) routesAuth(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe)))
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin)
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback)
|
||||
mux.HandleFunc("POST /api/v1/auth/forgot", s.handleForgotPassword)
|
||||
mux.HandleFunc("POST /api/v1/auth/reset", s.handleResetPassword)
|
||||
}
|
||||
|
||||
// handleForgotPassword issues a one-shot reset token by email (§11.22).
|
||||
// Active only when SMTP is configured; never reveals account existence.
|
||||
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if s.mailer == nil || !s.mailer.Enabled() {
|
||||
writeError(w, http.StatusServiceUnavailable, "smtp_disabled",
|
||||
"password reset by email is not configured on this server")
|
||||
return
|
||||
}
|
||||
if !s.loginLimiter.Allow("forgot|" + ClientIP(r.Context()).String()) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts")
|
||||
return
|
||||
}
|
||||
u, err := s.store.UserByEmail(r.Context(), req.Email)
|
||||
if err == nil && u.Auth.Local != nil && !u.Disabled {
|
||||
token := auth.NewToken()
|
||||
if err := s.store.CreatePasswordReset(r.Context(), token, u.ID, time.Hour); err != nil {
|
||||
s.internalError(w, r, "create reset", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.AppBaseURL + "/reset-password?token=" + token
|
||||
go func() {
|
||||
if err := s.mailer.Send(u.Email, "Reset your Bounty Board password",
|
||||
"Use this link within one hour to set a new password:\n\n"+link+
|
||||
"\n\nIf you did not request this, ignore this email."); err != nil {
|
||||
s.log.Error("send reset mail", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// uniform response regardless of account existence
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "sent_if_account_exists"})
|
||||
}
|
||||
|
||||
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < auth.MinPasswordLen {
|
||||
writeError(w, http.StatusBadRequest, "weak_password",
|
||||
fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen))
|
||||
return
|
||||
}
|
||||
userID, err := s.store.ConsumePasswordReset(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_token", "this reset link is invalid or expired")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetPassword(r.Context(), userID, hash, false); err != nil {
|
||||
s.internalError(w, r, "set password", err)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.DeleteUserSessions(r.Context(), userID); err != nil {
|
||||
s.log.Warn("revoke sessions after reset", "err", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// decodeJSON reads a bounded JSON body into dst, rejecting unknown fields.
|
||||
|
||||
+53
-33
@@ -33,28 +33,54 @@ func (s *Server) routesBoard(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard)))
|
||||
}
|
||||
|
||||
// boardConsultants resolves which consultants' tasks the developer sees:
|
||||
// the consultants who selected them into a pool (§3).
|
||||
func (s *Server) boardConsultants(r *http.Request) ([]string, error) {
|
||||
return s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
|
||||
// boardCustomers resolves the developer's visibility scope: every customer
|
||||
// that has at least one consultant who selected this developer into a pool.
|
||||
// Visibility is customer-based, not task-owner-based — §4.4: all consultants
|
||||
// assigned to a customer manage its tasks, so a task published by any of
|
||||
// them belongs to the same board.
|
||||
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
|
||||
consultants, err := s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []domain.Customer{}
|
||||
for _, cid := range consultants {
|
||||
customers, err := s.store.ListCustomers(r.Context(), cid, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range customers {
|
||||
if !seen[c.ID] {
|
||||
seen[c.ID] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
|
||||
consultants, err := s.boardConsultants(r)
|
||||
customers, err := s.boardCustomers(r)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "pool lookup", err)
|
||||
s.internalError(w, r, "board scope", err)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
customerIDs := []string{}
|
||||
for _, c := range customers {
|
||||
if want := q.Get("customerId"); want == "" || want == c.ID {
|
||||
customerIDs = append(customerIDs, c.ID)
|
||||
}
|
||||
}
|
||||
tasks := []domain.Task{}
|
||||
if len(consultants) > 0 {
|
||||
if len(customerIDs) > 0 {
|
||||
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
|
||||
filter := store.TaskFilter{
|
||||
ConsultantIDs: consultants,
|
||||
CustomerIDs: customerIDs,
|
||||
Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
|
||||
Search: strings.TrimSpace(q.Get("q")),
|
||||
MinBounty: minBounty,
|
||||
CustomerID: q.Get("customerId"),
|
||||
Sort: q.Get("sort"), // newest (default) | bounty
|
||||
Limit: 200,
|
||||
}
|
||||
@@ -78,18 +104,12 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
|
||||
tasks[i].ClaimRequests = mine
|
||||
}
|
||||
|
||||
// customers for the filter dropdown
|
||||
customerIDs := map[string]bool{}
|
||||
for _, t := range tasks {
|
||||
customerIDs[t.CustomerID] = true
|
||||
// customers for the filter dropdown (full visibility scope)
|
||||
customersOut := make([]map[string]any, len(customers))
|
||||
for i, c := range customers {
|
||||
customersOut[i] = map[string]any{"id": c.ID, "name": c.Name}
|
||||
}
|
||||
customers := []map[string]any{}
|
||||
for id := range customerIDs {
|
||||
if c, err := s.store.CustomerByID(r.Context(), id); err == nil {
|
||||
customers = append(customers, map[string]any{"id": c.ID, "name": c.Name})
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customers})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut})
|
||||
}
|
||||
|
||||
func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -110,8 +130,9 @@ func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
|
||||
}
|
||||
|
||||
// developerTask loads a task ensuring the developer can see it via a pool
|
||||
// relationship with its consultant.
|
||||
// developerTask loads a task ensuring the developer can see it: the task's
|
||||
// customer is in their pool-derived visibility scope, or they are the
|
||||
// assignee (access survives mid-flight pool removal).
|
||||
func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, bool) {
|
||||
t, err := s.store.TaskByID(r.Context(), id)
|
||||
if err != nil {
|
||||
@@ -122,20 +143,19 @@ func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
consultants, err := s.boardConsultants(r)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "pool lookup", err)
|
||||
return nil, false
|
||||
}
|
||||
for _, cid := range consultants {
|
||||
if cid == t.ConsultantID {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
// assignees keep access even if removed from the pool mid-flight
|
||||
if t.IsAssignedTo(CurrentUser(r.Context()).ID) {
|
||||
return t, true
|
||||
}
|
||||
customers, err := s.boardCustomers(r)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "board scope", err)
|
||||
return nil, false
|
||||
}
|
||||
for _, c := range customers {
|
||||
if c.ID == t.CustomerID {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "forbidden", "this task is not on your board")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -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,360 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"bountyboard/internal/chat"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/files"
|
||||
"bountyboard/internal/store"
|
||||
"bountyboard/internal/ws"
|
||||
)
|
||||
|
||||
func (s *Server) routesMessages(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/conversations", s.requireAuth(http.HandlerFunc(s.handleListConversations)))
|
||||
mux.Handle("POST /api/v1/conversations", s.authed(s.handleCreateConversation))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages)))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
|
||||
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
||||
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
||||
}
|
||||
|
||||
// conversation loads + authorizes participant access.
|
||||
func (s *Server) conversation(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
|
||||
conv, err := s.store.ConversationByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "conversation not found")
|
||||
} else {
|
||||
s.internalError(w, r, "load conversation", err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if !conv.HasParticipant(CurrentUser(r.Context()).ID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "you are not in this conversation")
|
||||
return nil, false
|
||||
}
|
||||
return conv, true
|
||||
}
|
||||
|
||||
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
convs, err := s.store.ListConversations(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list conversations", err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, len(convs))
|
||||
for i, c := range convs {
|
||||
ids[i] = c.ID
|
||||
}
|
||||
unread, err := s.store.UnreadCounts(r.Context(), me.ID, ids)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "unread counts", err)
|
||||
return
|
||||
}
|
||||
// resolve display names for the list
|
||||
out := make([]map[string]any, len(convs))
|
||||
for i, c := range convs {
|
||||
title := c.Title
|
||||
if c.Kind == "dm" {
|
||||
for _, pid := range c.ParticipantIDs {
|
||||
if pid != me.ID {
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
title = u.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = "Conversation"
|
||||
}
|
||||
out[i] = map[string]any{
|
||||
"id": c.ID, "kind": c.Kind, "title": title,
|
||||
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
|
||||
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"conversations": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
CustomerID string `json:"customerId"`
|
||||
ParticipantIDs []string `json:"participantIds"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
|
||||
// validate participants exist
|
||||
seen := map[string]bool{me.ID: true}
|
||||
participants := []string{me.ID}
|
||||
for _, id := range req.ParticipantIDs {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.UserByID(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_participant", "unknown user "+id)
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
participants = append(participants, id)
|
||||
}
|
||||
|
||||
switch req.Kind {
|
||||
case "dm":
|
||||
if len(participants) != 2 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "dm needs exactly one other participant")
|
||||
return
|
||||
}
|
||||
conv, err := s.store.FindOrCreateDM(r.Context(), participants[0], participants[1])
|
||||
if err != nil {
|
||||
s.internalError(w, r, "create dm", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"conversation": conv})
|
||||
return
|
||||
case "group", "project":
|
||||
if len(participants) < 2 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "need at least one other participant")
|
||||
return
|
||||
}
|
||||
conv := &store.Conversation{
|
||||
Kind: req.Kind,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
ParticipantIDs: participants,
|
||||
}
|
||||
if req.Kind == "project" {
|
||||
if req.CustomerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "project conversations need customerId")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CustomerByID(r.Context(), req.CustomerID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_customer", "unknown customer")
|
||||
return
|
||||
}
|
||||
conv.CustomerID = req.CustomerID
|
||||
}
|
||||
if conv.Title == "" && req.Kind == "group" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "group conversations need a title")
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateConversation(r.Context(), conv); err != nil {
|
||||
s.internalError(w, r, "create conversation", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conv})
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_kind", "kind must be dm, group or project")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
msgs, err := s.store.ListMessages(r.Context(), conv.ID, q.Get("cursor"),
|
||||
atoiDefault(q.Get("limit"), 50))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list messages", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
|
||||
}
|
||||
|
||||
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Body string `json:"body"`
|
||||
Attachments []string `json:"attachments"` // fileIds from POST /api/v1/files
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
body := chat.SanitizeHTML(req.Body)
|
||||
if strings.TrimSpace(chat.SanitizePlain(body)) == "" && len(req.Attachments) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty_message", "message needs text or attachments")
|
||||
return
|
||||
}
|
||||
|
||||
atts := []store.MessageAttachment{}
|
||||
for _, fid := range req.Attachments {
|
||||
rc, meta, err := s.files.Open(r.Context(), fid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_attachment", "unknown file "+fid)
|
||||
return
|
||||
}
|
||||
rc.Close()
|
||||
if meta.OwnerID != me.ID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "attachment was uploaded by someone else")
|
||||
return
|
||||
}
|
||||
atts = append(atts, store.MessageAttachment{
|
||||
FileID: meta.ID, Name: meta.Name, MimeType: meta.MimeType,
|
||||
Size: meta.Size, IsImage: strings.HasPrefix(meta.MimeType, "image/"),
|
||||
})
|
||||
}
|
||||
|
||||
msg := &store.Message{ConversationID: conv.ID, SenderID: me.ID, Body: body, Attachments: atts}
|
||||
if err := s.store.InsertMessage(r.Context(), msg); err != nil {
|
||||
s.internalError(w, r, "insert message", err)
|
||||
return
|
||||
}
|
||||
s.metrics.Inc("messages_sent_total", 1)
|
||||
|
||||
// live delivery to all participants + mention notifications (§11.1)
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
|
||||
}
|
||||
plain := chat.SanitizePlain(body)
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
if pid == me.ID {
|
||||
continue
|
||||
}
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
|
||||
lower := strings.ToLower(plain)
|
||||
if strings.Contains(lower, "@"+strings.ToLower(u.Email)) || strings.Contains(lower, "@"+first) {
|
||||
s.notifyUser(r.Context(), pid, "mention",
|
||||
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := s.store.MarkConversationRead(r.Context(), conv.ID, CurrentUser(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "mark read", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
|
||||
}
|
||||
|
||||
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
|
||||
// chat; scope=chat unless the caller passes scope=task (consultants).
|
||||
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
if !s.loginLimiter.Allow("upload|" + ClientIP(r.Context()).String()) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many uploads, slow down")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(s.cfg.MaxUploadMB)<<20+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "multipart field 'file' is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
scope := files.ScopeChat
|
||||
if r.FormValue("scope") == files.ScopeTask && (me.Roles.Consultant || me.Roles.Admin) {
|
||||
scope = files.ScopeTask
|
||||
}
|
||||
meta, err := s.files.Save(r.Context(), scope, me.ID, header.Filename,
|
||||
header.Header.Get("Content-Type"), file)
|
||||
if err != nil {
|
||||
if errors.Is(err, files.ErrTooLarge) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", err.Error())
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "save upload", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"fileId": meta.ID, "name": meta.Name, "mimeType": meta.MimeType,
|
||||
"size": meta.Size, "isImage": strings.HasPrefix(meta.MimeType, "image/"),
|
||||
})
|
||||
}
|
||||
|
||||
// handleSearchUsers is the small directory for picking conversation
|
||||
// participants (§11.10).
|
||||
func (s *Server) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
filter := bson.M{"disabled": false}
|
||||
if q != "" {
|
||||
filter["$or"] = []bson.M{
|
||||
{"email": bson.M{"$regex": regexEscape(q), "$options": "i"}},
|
||||
{"name": bson.M{"$regex": regexEscape(q), "$options": "i"}},
|
||||
}
|
||||
}
|
||||
cur, err := s.store.DB.Collection("users").Find(r.Context(), filter,
|
||||
options.Find().SetLimit(20).SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "search users", err)
|
||||
return
|
||||
}
|
||||
var users []domain.User
|
||||
if err := cur.All(r.Context(), &users); err != nil {
|
||||
s.internalError(w, r, "decode users", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, map[string]any{
|
||||
"id": u.ID, "name": u.Name, "email": u.Email, "avatarFileId": u.AvatarFileID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||
}
|
||||
|
||||
// HandleInboundWS relays ephemeral client events (typing indicator).
|
||||
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
|
||||
if msg.Channel != "chat" || msg.Event != "typing" {
|
||||
return
|
||||
}
|
||||
var data struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &data); err != nil || data.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conv, err := s.store.ConversationByID(ctx, data.ConversationID)
|
||||
if err != nil || !conv.HasParticipant(userID) {
|
||||
return
|
||||
}
|
||||
others := []string{}
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
if pid != userID {
|
||||
others = append(others, pid)
|
||||
}
|
||||
}
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(others, "chat", "typing", map[string]string{
|
||||
"conversationId": conv.ID, "userId": userID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -40,6 +40,7 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
||||
Settings *struct {
|
||||
Theme *string `json:"theme"`
|
||||
Notifications *domain.NotificationPrefs `json:"notifications"`
|
||||
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
|
||||
} `json:"settings"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
@@ -79,6 +80,9 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Settings.Notifications != nil {
|
||||
set["settings.notifications"] = *req.Settings.Notifications
|
||||
}
|
||||
if req.Settings.LeaderboardOptOut != nil {
|
||||
set["settings.leaderboardOptOut"] = *req.Settings.LeaderboardOptOut
|
||||
}
|
||||
}
|
||||
if len(set) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update")
|
||||
@@ -193,9 +197,25 @@ func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) {
|
||||
switch meta.Scope {
|
||||
case files.ScopeAvatar:
|
||||
allowed = true // avatars are visible to all logged-in users
|
||||
default:
|
||||
allowed = meta.OwnerID == user.ID || user.Roles.Admin ||
|
||||
user.Roles.Consultant // scoped tighter as chat/task features land
|
||||
case files.ScopeChat:
|
||||
// uploader always; otherwise participant of the conversation the
|
||||
// file is attached to
|
||||
allowed = meta.OwnerID == user.ID
|
||||
if !allowed {
|
||||
ok, err := s.store.UserCanAccessChatFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "chat file access", err)
|
||||
return
|
||||
}
|
||||
allowed = ok
|
||||
}
|
||||
default: // task scope: owner, admins, consultants, or the assignee
|
||||
allowed = meta.OwnerID == user.ID || user.Roles.Admin || user.Roles.Consultant
|
||||
if !allowed && user.Roles.Developer {
|
||||
// developers can fetch task files for tasks they can see;
|
||||
// signed URLs cover the external-service path
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "no access to this file")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func (s *Server) routesTasks(mux *http.ServeMux) {
|
||||
mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne))
|
||||
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
|
||||
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
|
||||
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
|
||||
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
|
||||
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
|
||||
}
|
||||
@@ -405,6 +406,44 @@ func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleArchiveBulk implements §11.9 bulk archive.
|
||||
func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 || len(req.IDs) > 100 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
|
||||
return
|
||||
}
|
||||
u := CurrentUser(r.Context())
|
||||
archived := []string{}
|
||||
failed := map[string]string{}
|
||||
for _, id := range req.IDs {
|
||||
t, err := s.store.TaskByID(r.Context(), id)
|
||||
if err != nil {
|
||||
failed[id] = "not found"
|
||||
continue
|
||||
}
|
||||
c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
|
||||
if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) {
|
||||
failed[id] = "not your customer"
|
||||
continue
|
||||
}
|
||||
if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
|
||||
failed[id] = err.Error()
|
||||
continue
|
||||
}
|
||||
archived = append(archived, id)
|
||||
}
|
||||
if len(archived) > 0 {
|
||||
s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed})
|
||||
}
|
||||
|
||||
// handleTaskDetail is role-scoped: admins and customer consultants always;
|
||||
// developers when they are the assignee, have a claim, or the task is on the
|
||||
// public board (published/claim_requested).
|
||||
|
||||
+63
-16
@@ -1,13 +1,17 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"bountyboard/api"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/web"
|
||||
)
|
||||
@@ -124,8 +128,19 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
if err != nil {
|
||||
panic(err) // embed layout is fixed at compile time
|
||||
}
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/",
|
||||
cacheControl(http.FileServerFS(staticFS), "public, max-age=3600")))
|
||||
// no-cache + a build-wide strong ETag: browsers revalidate on every
|
||||
// load (cheap 304) and can never run a stale CSS/JS mix after a deploy
|
||||
etag := staticETag(staticFS)
|
||||
fileServer := http.StripPrefix("/static/", http.FileServerFS(staticFS))
|
||||
mux.Handle("GET /static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("ETag", etag)
|
||||
fileServer.ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /{$}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "home.html", &pageData{Title: "Home", User: u})
|
||||
@@ -155,6 +170,17 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
})
|
||||
})
|
||||
|
||||
for _, p := range []struct{ path, tmpl, title, script string }{
|
||||
{"/forgot-password", "forgot-password.html", "Forgot password", "/static/js/forgot-password.js"},
|
||||
{"/reset-password", "reset-password.html", "Reset password", "/static/js/reset-password.js"},
|
||||
} {
|
||||
mux.HandleFunc("GET "+p.path, func(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, p.tmpl, &pageData{
|
||||
Title: p.title, Narrow: true, Scripts: []string{p.script},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
mux.HandleFunc("GET /change-password", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "change-password.html", &pageData{
|
||||
Title: "Change password", User: u, Narrow: true,
|
||||
@@ -219,16 +245,26 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
})
|
||||
}))
|
||||
|
||||
// Placeholders for areas built in later phases; replaced as they land.
|
||||
placeholders := map[string]string{
|
||||
"/messages": "Messages",
|
||||
"/metrics": "Metrics",
|
||||
}
|
||||
for path, title := range placeholders {
|
||||
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "placeholder.html", &pageData{Title: title, User: u})
|
||||
}))
|
||||
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
|
||||
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
|
||||
}
|
||||
|
||||
// routesDocs serves the OpenAPI document and a minimal viewer (§6).
|
||||
func (s *Server) routesDocs(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml")
|
||||
w.Write(api.OpenAPI)
|
||||
})
|
||||
mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<title>Bounty Board API</title><link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="/static/js/theme.js"></script></head>
|
||||
<body><main class="container"><h1>Bounty Board API</h1>
|
||||
<p><a class="btn" href="/api/openapi.yaml" download>Download openapi.yaml</a></p>
|
||||
<pre style="white-space:pre-wrap;background:var(--surface);border:1px solid var(--border);padding:16px;border-radius:var(--radius)">` +
|
||||
html.EscapeString(string(api.OpenAPI)) + `</pre></main></body></html>`))
|
||||
})
|
||||
}
|
||||
|
||||
func loginErrorMessage(code string) string {
|
||||
@@ -250,9 +286,20 @@ func loginErrorMessage(code string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func cacheControl(next http.Handler, value string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", value)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
// staticETag hashes every embedded static asset into one build-wide ETag.
|
||||
func staticETag(fsys fs.FS) string {
|
||||
h := sha256.New()
|
||||
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Write([]byte(path))
|
||||
h.Write(data)
|
||||
return nil
|
||||
})
|
||||
return `"` + hex.EncodeToString(h.Sum(nil))[:20] + `"`
|
||||
}
|
||||
|
||||
@@ -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,228 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"bountyboard/internal/ulid"
|
||||
)
|
||||
|
||||
// Conversation per §4.6.
|
||||
type Conversation struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
Kind string `bson:"kind" json:"kind"` // dm | group | project
|
||||
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
|
||||
Title string `bson:"title,omitempty" json:"title,omitempty"`
|
||||
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
|
||||
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
|
||||
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
||||
}
|
||||
|
||||
// MessageAttachment per §4.6.
|
||||
type MessageAttachment struct {
|
||||
FileID string `bson:"fileId" json:"fileId"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
MimeType string `bson:"mimeType" json:"mimeType"`
|
||||
Size int64 `bson:"size" json:"size"`
|
||||
IsImage bool `bson:"isImage" json:"isImage"`
|
||||
}
|
||||
|
||||
type ReadReceipt struct {
|
||||
UserID string `bson:"userId" json:"userId"`
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
ConversationID string `bson:"conversationId" json:"conversationId"`
|
||||
SenderID string `bson:"senderId" json:"senderId"`
|
||||
Body string `bson:"body" json:"body"` // sanitized rich text
|
||||
Attachments []MessageAttachment `bson:"attachments" json:"attachments"`
|
||||
ReadBy []ReadReceipt `bson:"readBy" json:"readBy"`
|
||||
EditedAt *time.Time `bson:"editedAt" json:"editedAt"`
|
||||
DeletedAt *time.Time `bson:"deletedAt" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (c *Conversation) HasParticipant(userID string) bool {
|
||||
for _, id := range c.ParticipantIDs {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FindOrCreateDM returns the unique dm between two users, creating it on
|
||||
// first contact (§4.6: participantIds is the dm unique key).
|
||||
func (s *Store) FindOrCreateDM(ctx context.Context, a, b string) (*Conversation, error) {
|
||||
var conv Conversation
|
||||
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{
|
||||
"kind": "dm",
|
||||
"participantIds": bson.M{"$all": []string{a, b}, "$size": 2},
|
||||
}).Decode(&conv)
|
||||
if err == nil {
|
||||
return &conv, nil
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, fmt.Errorf("find dm: %w", err)
|
||||
}
|
||||
conv = Conversation{
|
||||
ID: ulid.New(), Kind: "dm", ParticipantIDs: []string{a, b},
|
||||
LastMessageAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := s.DB.Collection("conversations").InsertOne(ctx, conv); err != nil {
|
||||
return nil, fmt.Errorf("create dm: %w", err)
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateConversation(ctx context.Context, c *Conversation) error {
|
||||
c.ID = ulid.New()
|
||||
c.CreatedAt = time.Now().UTC()
|
||||
c.LastMessageAt = c.CreatedAt
|
||||
if _, err := s.DB.Collection("conversations").InsertOne(ctx, c); err != nil {
|
||||
return fmt.Errorf("create conversation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation, error) {
|
||||
var c Conversation
|
||||
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{"_id": id}).Decode(&c)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find conversation: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
|
||||
cur, err := s.DB.Collection("conversations").Find(ctx,
|
||||
bson.M{"participantIds": userID},
|
||||
options.Find().SetSort(bson.D{{Key: "lastMessageAt", Value: -1}}).SetLimit(100))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list conversations: %w", err)
|
||||
}
|
||||
out := []Conversation{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode conversations: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnreadCounts returns per-conversation unread message counts for the user.
|
||||
func (s *Store) UnreadCounts(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
|
||||
if len(convIDs) == 0 {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
cur, err := s.DB.Collection("messages").Aggregate(ctx, mongo.Pipeline{
|
||||
{{Key: "$match", Value: bson.M{
|
||||
"conversationId": bson.M{"$in": convIDs},
|
||||
"senderId": bson.M{"$ne": userID},
|
||||
"deletedAt": nil,
|
||||
"readBy.userId": bson.M{"$ne": userID},
|
||||
}}},
|
||||
{{Key: "$group", Value: bson.M{"_id": "$conversationId", "n": bson.M{"$sum": 1}}}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unread counts: %w", err)
|
||||
}
|
||||
var rows []struct {
|
||||
ID string `bson:"_id"`
|
||||
N int64 `bson:"n"`
|
||||
}
|
||||
if err := cur.All(ctx, &rows); err != nil {
|
||||
return nil, fmt.Errorf("decode unread: %w", err)
|
||||
}
|
||||
out := make(map[string]int64, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.ID] = r.N
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertMessage stores the message and bumps the conversation timestamp.
|
||||
func (s *Store) InsertMessage(ctx context.Context, m *Message) error {
|
||||
m.ID = ulid.New()
|
||||
if m.Attachments == nil {
|
||||
m.Attachments = []MessageAttachment{}
|
||||
}
|
||||
m.ReadBy = []ReadReceipt{{UserID: m.SenderID, At: time.Now().UTC()}}
|
||||
if _, err := s.DB.Collection("messages").InsertOne(ctx, m); err != nil {
|
||||
return fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||
bson.M{"_id": m.ConversationID},
|
||||
bson.M{"$set": bson.M{"lastMessageAt": time.Now().UTC()}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("bump conversation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMessages returns up to limit messages older than cursor (ULID order),
|
||||
// newest last.
|
||||
func (s *Store) ListMessages(ctx context.Context, convID, cursor string, limit int) ([]Message, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
q := bson.M{"conversationId": convID}
|
||||
if cursor != "" {
|
||||
q["_id"] = bson.M{"$lt": cursor}
|
||||
}
|
||||
cur, err := s.DB.Collection("messages").Find(ctx, q,
|
||||
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(int64(limit)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list messages: %w", err)
|
||||
}
|
||||
out := []Message{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode messages: %w", err)
|
||||
}
|
||||
// reverse to chronological order
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkConversationRead adds a read receipt to every unread message.
|
||||
func (s *Store) MarkConversationRead(ctx context.Context, convID, userID string) (int64, error) {
|
||||
res, err := s.DB.Collection("messages").UpdateMany(ctx,
|
||||
bson.M{
|
||||
"conversationId": convID,
|
||||
"readBy.userId": bson.M{"$ne": userID},
|
||||
},
|
||||
bson.M{"$push": bson.M{"readBy": ReadReceipt{UserID: userID, At: time.Now().UTC()}}})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("mark read: %w", err)
|
||||
}
|
||||
return res.ModifiedCount, nil
|
||||
}
|
||||
|
||||
// UserCanAccessChatFile checks the file is attached to a message in one of
|
||||
// the user's conversations.
|
||||
func (s *Store) UserCanAccessChatFile(ctx context.Context, userID, fileID string) (bool, error) {
|
||||
var msg Message
|
||||
err := s.DB.Collection("messages").FindOne(ctx,
|
||||
bson.M{"attachments.fileId": fileID}).Decode(&msg)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("find file message: %w", err)
|
||||
}
|
||||
conv, err := s.ConversationByID(ctx, msg.ConversationID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return conv.HasParticipant(userID), nil
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/files"
|
||||
"bountyboard/internal/metrics"
|
||||
@@ -287,12 +289,24 @@ func ticketingIdentity(u *domain.User, t domain.TicketingType) string {
|
||||
if t == domain.TicketingDemo {
|
||||
return u.Email // demo accepts any identity
|
||||
}
|
||||
ids, _ := u.Extra["ticketingIdentities"].(map[string]any)
|
||||
if ids == nil {
|
||||
return ""
|
||||
}
|
||||
// The driver decodes the nested document as bson.D when the field type
|
||||
// is `any`; JSON-built values arrive as map[string]any. Handle both.
|
||||
switch ids := u.Extra["ticketingIdentities"].(type) {
|
||||
case map[string]any:
|
||||
v, _ := ids[string(t)].(string)
|
||||
return v
|
||||
case bson.M:
|
||||
v, _ := ids[string(t)].(string)
|
||||
return v
|
||||
case bson.D:
|
||||
for _, e := range ids {
|
||||
if e.Key == string(t) {
|
||||
v, _ := e.Value.(string)
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func contentHash(t Ticket) string {
|
||||
|
||||
@@ -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)));
|
||||
+565
-88
@@ -1,136 +1,393 @@
|
||||
/* Bounty Board — hand-written CSS, no framework (§2.2).
|
||||
Theming via CSS custom properties on <html data-theme>. */
|
||||
/* Bounty Board — "The Ledger".
|
||||
A vintage bounty-poster / financial-ledger aesthetic on warm paper:
|
||||
engraved display serif, characterful grotesk UI, tabular mono numerals,
|
||||
hairline double-rules, stamped badges, paper grain. Hand-written CSS, no
|
||||
framework (§2.2). Design tokens below are the exact §10 values. */
|
||||
|
||||
/* ---- self-hosted variable fonts (no build step, CSP: font-src 'self') ---- */
|
||||
@font-face {
|
||||
font-family: "Fraunces";
|
||||
src: url("/static/fonts/Fraunces-var.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Schibsted Grotesk";
|
||||
src: url("/static/fonts/SchibstedGrotesk-var.woff2") format("woff2");
|
||||
font-weight: 400 900;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Spline Sans Mono";
|
||||
src: url("/static/fonts/SplineSansMono-var.woff2") format("woff2");
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ---- design tokens — "Y2K dither" rework (user-requested deviation from
|
||||
the §10 beige): paper-white ground, brown ink everywhere, ordered-dither
|
||||
halftone textures, hard offset shadows. Structure and radius unchanged. */
|
||||
:root[data-theme=light] {
|
||||
--bg:#f3ead9; --surface:#faf5ea; --surface2:#efe5d0; --border:#d8cbb0;
|
||||
--text:#2b2620; --muted:#6f6353; --accent:#8a5a2b; --accent-contrast:#fff;
|
||||
--ok:#3c6e47; --warn:#a06a1f; --err:#9c3a2e; --radius:2px;
|
||||
--bg:#ffffff; --surface:#fdfbf6; --surface2:#f2e9d8; --border:#a88452;
|
||||
--text:#33230e; --muted:#7c6647; --accent:#7a4a14; --accent-contrast:#fff;
|
||||
--ok:#2e6b3c; --warn:#955d0e; --err:#9c3a2e; --radius:2px;
|
||||
}
|
||||
:root[data-theme=dark] {
|
||||
--bg:#191714; --surface:#221f1b; --surface2:#2b2722; --border:#3a342c;
|
||||
--text:#ece5d8; --muted:#a59a87; --accent:#caa15e; --accent-contrast:#1a160f;
|
||||
--bg:#171209; --surface:#221a0e; --surface2:#2e2414; --border:#7c5f33;
|
||||
--text:#f3e8d2; --muted:#b59c74; --accent:#d9a548; --accent-contrast:#241606;
|
||||
--ok:#7fb78a; --warn:#d9a44a; --err:#d97b6c; --radius:2px;
|
||||
}
|
||||
|
||||
/* derived, theme-aware atmosphere */
|
||||
:root {
|
||||
--font-display: "Fraunces", Georgia, serif;
|
||||
--font-body: "Schibsted Grotesk", system-ui, sans-serif;
|
||||
--font-mono: "Spline Sans Mono", ui-monospace, monospace;
|
||||
--hairline: 1px solid var(--border);
|
||||
}
|
||||
:root[data-theme=light] {
|
||||
/* Y2K hard offset shadow, no blur */
|
||||
--ink-shadow: 3px 3px 0 color-mix(in srgb, var(--border) 45%, transparent);
|
||||
--ink-shadow-lift: 5px 5px 0 color-mix(in srgb, var(--border) 55%, transparent);
|
||||
/* ordered-dither tile: sparse diagonal brown pixels on transparent */
|
||||
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%237a4a14'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%237a4a14'/%3E%3C/svg%3E");
|
||||
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%237a4a14'/%3E%3C/svg%3E");
|
||||
}
|
||||
:root[data-theme=dark] {
|
||||
--ink-shadow: 3px 3px 0 rgba(0, 0, 0, 0.55);
|
||||
--ink-shadow-lift: 5px 5px 0 rgba(0, 0, 0, 0.65);
|
||||
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%23d9a548'/%3E%3C/svg%3E");
|
||||
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ---- base ---- */
|
||||
* { box-sizing: border-box; }
|
||||
/* author display rules (flex etc.) must never defeat the hidden attribute */
|
||||
[hidden] { display: none !important; }
|
||||
html { font-size: 16px; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.55;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
/* dithered poster band fading from the masthead into the white page */
|
||||
body::before {
|
||||
content: "";
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 220px;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: var(--dither);
|
||||
opacity: 0.5;
|
||||
-webkit-mask-image: linear-gradient(#000, transparent);
|
||||
mask-image: linear-gradient(#000, transparent);
|
||||
}
|
||||
::selection { background: var(--accent); color: var(--accent-contrast); }
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
h1, h2, h3 { line-height: 1.25; margin: 0 0 16px; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.2rem; }
|
||||
h3 { font-size: 1rem; }
|
||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.005em;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
h1 { font-size: 2.1rem; }
|
||||
h1::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 88px; height: 8px;
|
||||
margin-top: 10px;
|
||||
background-image: var(--dither-dense);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
h2 { font-size: 1.25rem; }
|
||||
h3 { font-size: 1.02rem; }
|
||||
code, pre { font-family: var(--font-mono); }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* numerals everywhere read like a ledger */
|
||||
table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---- layout ---- */
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 24px 16px; }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 32px 16px 64px; }
|
||||
.narrow { max-width: 460px; }
|
||||
|
||||
/* staggered page reveal (one orchestrated moment, then calm) */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
main.container > * {
|
||||
animation: rise 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) backwards;
|
||||
}
|
||||
main.container > *:nth-child(1) { animation-delay: 0.03s; }
|
||||
main.container > *:nth-child(2) { animation-delay: 0.09s; }
|
||||
main.container > *:nth-child(3) { animation-delay: 0.15s; }
|
||||
main.container > *:nth-child(4) { animation-delay: 0.21s; }
|
||||
main.container > *:nth-child(5) { animation-delay: 0.27s; }
|
||||
main.container > *:nth-child(n+6) { animation-delay: 0.33s; }
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ---- top navigation: dithered masthead bar ---- */
|
||||
.topnav {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
background-color: var(--surface2);
|
||||
background-image: var(--dither);
|
||||
background-blend-mode: normal;
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding: 10px 20px;
|
||||
position: relative;
|
||||
}
|
||||
.topnav .brand { font-weight: 700; color: var(--text); }
|
||||
.topnav .links { display: flex; gap: 8px; flex: 1; flex-wrap: wrap; }
|
||||
:root[data-theme=light] .topnav { background-image: none; background-color: var(--surface2); }
|
||||
.topnav::after {
|
||||
content: "";
|
||||
position: absolute; bottom: -10px; left: 0; right: 0; height: 8px;
|
||||
background-image: var(--dither);
|
||||
-webkit-mask-image: linear-gradient(#000, transparent);
|
||||
mask-image: linear-gradient(#000, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.topnav::before {
|
||||
content: "";
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 3px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.topnav .brand {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.18rem;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topnav .brand::before {
|
||||
content: "◈ ";
|
||||
color: var(--accent);
|
||||
}
|
||||
.topnav .brand:hover { text-decoration: none; }
|
||||
.topnav .links { display: flex; gap: 2px; flex: 1; flex-wrap: wrap; }
|
||||
.topnav .links a {
|
||||
color: var(--muted); padding: 6px 10px; border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
padding: 7px 11px;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: 0.02em;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.topnav .links a:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.topnav .links a[aria-current=page] {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.topnav .links a:hover { color: var(--text); background: var(--surface2); text-decoration: none; }
|
||||
.topnav .links a[aria-current=page] { color: var(--text); background: var(--surface2); }
|
||||
.topnav .right { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ---- components ---- */
|
||||
/* ---- cards: printed stock with hard Y2K shadows ---- */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--ink-shadow);
|
||||
padding: 24px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.card + .card { margin-top: 16px; }
|
||||
/* the stacking margin must never leak into grid/flex layouts — it shifted
|
||||
every card except the first one ("always the first item" bug) */
|
||||
.grid > .card, .kanban .card, .row > .card { margin-top: 0; }
|
||||
.grid > .card { position: relative; overflow: hidden; }
|
||||
.grid > .card::before {
|
||||
content: "";
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 6px;
|
||||
background-image: var(--dither-dense);
|
||||
border-bottom: 1px solid var(--accent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.grid > .card:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: var(--ink-shadow-lift);
|
||||
}
|
||||
|
||||
/* ---- buttons: Y2K hard-shadow chips ---- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font: inherit; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
margin: 0; /* label-as-button must not inherit label margins */
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
box-shadow: 2px 2px 0 color-mix(in srgb, var(--border) 60%, transparent);
|
||||
transition: transform 0.07s ease, box-shadow 0.07s ease, filter 0.12s ease;
|
||||
}
|
||||
.btn:hover { filter: brightness(0.97); }
|
||||
.btn:hover { filter: brightness(0.97); text-decoration: none; }
|
||||
.btn:active { transform: translate(2px, 2px); box-shadow: none; }
|
||||
.btn.primary {
|
||||
background: var(--accent); color: var(--accent-contrast); border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
border-color: color-mix(in srgb, var(--accent) 65%, black);
|
||||
}
|
||||
.btn.danger { background: var(--err); color: #fff; border-color: var(--err); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn.small { padding: 4px 8px; font-size: 0.875rem; }
|
||||
.btn.danger { background: var(--err); color: #fff; border-color: color-mix(in srgb, var(--err) 65%, black); }
|
||||
.btn.ghost { background: transparent; border-color: transparent; box-shadow: none; }
|
||||
.btn.ghost:hover { border-color: var(--border); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||
.btn.small { padding: 4px 10px; font-size: 0.74rem; box-shadow: 1px 1px 0 color-mix(in srgb, var(--border) 60%, transparent); }
|
||||
|
||||
label { display: block; font-weight: 600; margin-bottom: 4px; }
|
||||
.hint { color: var(--muted); font-size: 0.875rem; margin: 4px 0 0; }
|
||||
/* ---- forms ---- */
|
||||
label { display: block; font-weight: 700; font-size: 0.88rem; letter-spacing: 0.02em; margin-bottom: 5px; }
|
||||
.hint { color: var(--muted); font-size: 0.86rem; margin: 5px 0 0; }
|
||||
input[type=text], input[type=email], input[type=password], input[type=number],
|
||||
select, textarea {
|
||||
input[type=search], input[type=date], input[type=file], select, textarea {
|
||||
width: 100%;
|
||||
font: inherit; color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px;
|
||||
padding: 9px 10px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
.field { margin-bottom: 16px; }
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
textarea { resize: vertical; min-height: 84px; }
|
||||
.field { margin-bottom: 18px; }
|
||||
.row { display: flex; gap: 16px; }
|
||||
.row > * { flex: 1; }
|
||||
|
||||
.error-box, .ok-box {
|
||||
border: 1px solid var(--err); color: var(--err);
|
||||
border: 1px solid var(--err);
|
||||
border-left-width: 4px;
|
||||
color: var(--err);
|
||||
background: color-mix(in srgb, var(--err) 7%, var(--surface));
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px; margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ok-box {
|
||||
border-color: var(--ok); color: var(--ok);
|
||||
background: color-mix(in srgb, var(--ok) 8%, var(--surface));
|
||||
}
|
||||
.ok-box { border-color: var(--ok); color: var(--ok); }
|
||||
.error-box:empty, .ok-box:empty { display: none; }
|
||||
|
||||
/* ---- badges: rubber stamps & banknote chips ---- */
|
||||
.badge {
|
||||
display: inline-block; font-size: 0.75rem; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: var(--radius);
|
||||
background: var(--surface2); color: var(--muted);
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.badge.accent { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
|
||||
.badge.accent {
|
||||
font-weight: 700;
|
||||
background: var(--surface);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
box-shadow: inset 0 0 0 2px var(--surface), inset 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px; height: 32px; border-radius: var(--radius);
|
||||
background: var(--accent); color: var(--accent-contrast);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 0.875rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700; font-size: 0.9rem;
|
||||
object-fit: cover; overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 70%, black);
|
||||
}
|
||||
.avatar.large { width: 96px; height: 96px; font-size: 2rem; }
|
||||
.avatar.large { width: 96px; height: 96px; font-size: 2.1rem; }
|
||||
img.avatar { background: var(--surface2); }
|
||||
|
||||
/* ---- tables: the ledger itself ---- */
|
||||
table.list { width: 100%; border-collapse: collapse; }
|
||||
table.list th, table.list td {
|
||||
text-align: left; padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left; padding: 9px 10px;
|
||||
border-bottom: var(--hairline);
|
||||
}
|
||||
table.list th { color: var(--muted); font-size: 0.875rem; }
|
||||
table.list th {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
table.list tbody tr:hover { background: color-mix(in srgb, var(--surface2) 60%, transparent); }
|
||||
table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface2) 28%, transparent); }
|
||||
|
||||
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.grid > .card { display: flex; flex-direction: column; } /* equal-height rows */
|
||||
|
||||
/* ---- toolbar: filter rows boxed and baseline-aligned ---- */
|
||||
.toolbar {
|
||||
display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap;
|
||||
background: var(--surface);
|
||||
border: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--ink-shadow);
|
||||
padding: 14px 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.toolbar::before {
|
||||
content: "";
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 5px;
|
||||
background-image: var(--dither-dense);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.toolbar .field { margin-bottom: 0; flex: 1 1 150px; }
|
||||
.toolbar .field.tight { flex: 0 1 auto; }
|
||||
.toolbar > .btn { flex: 0 0 auto; height: 41px; } /* matches input height */
|
||||
.toolbar label { white-space: nowrap; }
|
||||
|
||||
/* ---- stat cards (metrics) ---- */
|
||||
.stat { display: flex; flex-direction: column; gap: 4px; }
|
||||
.stat .stat-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem; font-weight: 500;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
.stat .stat-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.9rem; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
.stat .hint { margin-top: auto; }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||||
@@ -140,57 +397,167 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
|
||||
.kv-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.kv-row input { flex: 1; }
|
||||
|
||||
/* toast notifications */
|
||||
/* ---- toasts: telegram slips (above the chat bubble) ---- */
|
||||
#toasts {
|
||||
position: fixed; bottom: 16px; right: 16px; z-index: 100;
|
||||
position: fixed; bottom: 84px; right: 16px; z-index: 2147483647;
|
||||
display: flex; flex-direction: column; gap: 8px; max-width: 360px;
|
||||
}
|
||||
.toast {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--surface);
|
||||
border: var(--hairline);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
font-size: 0.92rem;
|
||||
box-shadow: 0 10px 24px -10px rgba(0,0,0,0.4);
|
||||
animation: slipIn 0.25s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
@keyframes slipIn {
|
||||
from { opacity: 0; transform: translateX(12px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
|
||||
/* kanban */
|
||||
.kanban { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.kanban-col {
|
||||
background: var(--surface2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px; min-height: 200px;
|
||||
/* ---- masthead (login / register) ---- */
|
||||
.masthead { text-align: center; margin-bottom: 26px; }
|
||||
.masthead-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.masthead-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 2.6rem;
|
||||
letter-spacing: 0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
.masthead-title::after { content: none; }
|
||||
.masthead-rule {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 14px auto 0; max-width: 280px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.masthead-rule::before, .masthead-rule::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-top: var(--hairline);
|
||||
border-bottom: var(--hairline);
|
||||
height: 3px;
|
||||
}
|
||||
.kanban-col h2 { font-size: 0.9rem; text-transform: uppercase; color: var(--muted); }
|
||||
|
||||
/* notification bell */
|
||||
/* ---- hover cards ---- */
|
||||
.hovercard {
|
||||
position: absolute; z-index: 90; width: 300px;
|
||||
box-shadow: 0 14px 30px -12px rgba(0,0,0,0.45);
|
||||
animation: rise 0.18s ease;
|
||||
}
|
||||
|
||||
/* ---- chat ---- */
|
||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; min-height: 70vh; }
|
||||
.chat-sidebar { overflow: auto; }
|
||||
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||
.conv-list li { border-bottom: var(--hairline); }
|
||||
.conv-list li.active .conv-item {
|
||||
background: var(--surface2);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
.conv-item {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 11px 10px; font: inherit; text-align: left;
|
||||
background: none; border: none; color: var(--text); cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.conv-item:hover { background: var(--surface2); }
|
||||
.chat-main { display: flex; flex-direction: column; }
|
||||
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; }
|
||||
.chat-msg {
|
||||
max-width: 75%; margin: 10px 0; padding: 9px 13px;
|
||||
background: var(--surface2);
|
||||
border: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.chat-msg.own {
|
||||
margin-left: auto;
|
||||
background: color-mix(in srgb, var(--accent) 11%, var(--surface2));
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
.chat-msg p, .chat-msg ul, .chat-msg ol { margin: 4px 0; }
|
||||
.chat-img { max-width: 240px; max-height: 180px; cursor: zoom-in; border-radius: var(--radius); border: var(--hairline); }
|
||||
.chat-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
|
||||
.chat-composer {
|
||||
min-height: 60px; max-height: 200px; overflow: auto;
|
||||
background: var(--bg);
|
||||
border: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
}
|
||||
.chat-composer:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.chat-composer:empty::before { content: attr(data-placeholder); color: var(--muted); }
|
||||
.chat-attachments { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.lightbox { border: none; background: transparent; max-width: 92vw; max-height: 92vh; }
|
||||
.lightbox img { max-width: 90vw; max-height: 88vh; cursor: zoom-out; border: 4px solid var(--surface); }
|
||||
.lightbox::backdrop { background: rgba(12, 10, 8, 0.85); }
|
||||
@media (max-width: 800px) { .chat-layout { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ---- kanban: ledger columns ---- */
|
||||
.kanban { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.kanban-col {
|
||||
background: color-mix(in srgb, var(--surface2) 55%, transparent);
|
||||
border: var(--hairline);
|
||||
border-top: 3px double var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
min-height: 220px;
|
||||
}
|
||||
.kanban-col h2 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---- notification bell ---- */
|
||||
.bell-badge {
|
||||
position: absolute; top: -4px; right: -4px;
|
||||
background: var(--err); color: #fff;
|
||||
font-size: 0.65rem; font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem; font-weight: 700;
|
||||
padding: 1px 5px; border-radius: var(--radius);
|
||||
}
|
||||
.notif-panel {
|
||||
position: absolute; right: 0; top: 36px; z-index: 50;
|
||||
width: 320px; max-height: 420px; overflow: auto;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
position: absolute; right: 0; top: 38px; z-index: 50;
|
||||
width: 330px; max-height: 420px; overflow: auto;
|
||||
background: var(--surface);
|
||||
border: var(--hairline);
|
||||
border-top: 3px double var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
box-shadow: 0 16px 32px -12px rgba(0,0,0,0.45);
|
||||
animation: rise 0.18s ease;
|
||||
}
|
||||
.notif-list { list-style: none; margin: 0 0 8px; padding: 0; }
|
||||
.notif-list li { padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.notif-list a { color: var(--text); text-decoration: none; }
|
||||
.notif-list li { padding: 9px 0; border-bottom: var(--hairline); }
|
||||
.notif-list a { color: var(--text); }
|
||||
.notif-list a:hover { text-decoration: none; opacity: 0.85; }
|
||||
|
||||
/* atomizing progress shimmer */
|
||||
.shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* ---- atomizing shimmer ---- */
|
||||
.shimmer { position: relative; overflow: hidden; }
|
||||
.shimmer::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 12%, transparent), transparent);
|
||||
background: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%);
|
||||
animation: shimmer 1.6s infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
@@ -199,22 +566,132 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
|
||||
}
|
||||
input[type=range] { width: 100%; accent-color: var(--accent); }
|
||||
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.tabs [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
|
||||
/* ---- tabs ---- */
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; }
|
||||
.tabs [aria-selected=true] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, black);
|
||||
}
|
||||
|
||||
/* ---- dialogs: paper slips ---- */
|
||||
dialog {
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 24px; max-width: 90vw; max-height: 90vh; overflow: auto;
|
||||
border: var(--hairline);
|
||||
border-top: 4px double var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 26px;
|
||||
max-width: 90vw; max-height: 90vh; overflow: auto;
|
||||
box-shadow: 0 30px 70px -20px rgba(0,0,0,0.55);
|
||||
}
|
||||
dialog::backdrop { background: rgba(0,0,0,0.4); }
|
||||
dialog[open] { animation: rise 0.22s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
dialog::backdrop { background: rgba(20, 16, 12, 0.55); }
|
||||
fieldset {
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
border: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
margin: 0 0 16px; padding: 16px;
|
||||
}
|
||||
legend { font-weight: 600; padding: 0 8px; }
|
||||
legend {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* ---- new-conversation dialog: fixed geometry, no jumping ---- */
|
||||
#newconv-form { width: 440px; max-width: 86vw; }
|
||||
#nc-results {
|
||||
height: 180px; /* fixed: search results never resize the dialog */
|
||||
overflow: auto;
|
||||
border: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
margin-top: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
#nc-results .nc-row {
|
||||
display: block; width: 100%;
|
||||
text-align: left;
|
||||
font: inherit; font-size: 0.92rem;
|
||||
background: none; border: none; color: var(--text);
|
||||
padding: 8px 10px; cursor: pointer;
|
||||
border-bottom: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
#nc-results .nc-row:hover { background: var(--surface2); }
|
||||
#nc-results .nc-empty { color: var(--muted); padding: 10px; font-size: 0.88rem; }
|
||||
#nc-selected { min-height: 30px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* ---- floating messages bubble + mini panel ---- */
|
||||
.chat-fab {
|
||||
position: fixed; bottom: 16px; right: 16px; z-index: 2147483645;
|
||||
width: 52px; height: 52px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.35rem;
|
||||
background: var(--accent); color: var(--accent-contrast);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 65%, black);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 3px 3px 0 color-mix(in srgb, var(--border) 70%, transparent);
|
||||
cursor: pointer;
|
||||
transition: transform 0.08s ease, box-shadow 0.08s ease;
|
||||
}
|
||||
.chat-fab:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 color-mix(in srgb, var(--border) 70%, transparent); }
|
||||
.chat-fab:active { transform: translate(2px, 2px); box-shadow: none; }
|
||||
.chat-fab .bell-badge { top: -6px; right: -6px; }
|
||||
.chat-panel {
|
||||
position: fixed; bottom: 80px; right: 16px; z-index: 2147483645;
|
||||
width: 350px; height: 480px; max-height: 75vh;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: var(--hairline);
|
||||
border-top: 4px double var(--accent);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 6px 6px 0 color-mix(in srgb, var(--border) 50%, transparent);
|
||||
animation: rise 0.18s ease;
|
||||
}
|
||||
.chat-panel header {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
background-image: var(--dither);
|
||||
background-color: var(--surface2);
|
||||
}
|
||||
.chat-panel header strong {
|
||||
font-family: var(--font-display); font-size: 1rem;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.chat-panel header .btn { background: var(--surface); }
|
||||
.chat-panel .cw-body { flex: 1; overflow: auto; padding: 6px 10px; }
|
||||
.chat-panel .cw-conv {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 10px 8px; font: inherit; text-align: left;
|
||||
background: none; border: none; border-bottom: var(--hairline);
|
||||
color: var(--text); cursor: pointer; border-radius: var(--radius);
|
||||
}
|
||||
.chat-panel .cw-conv:hover { background: var(--surface2); }
|
||||
.chat-panel .cw-msg {
|
||||
max-width: 85%; margin: 8px 0; padding: 7px 10px;
|
||||
font-size: 0.88rem;
|
||||
background: var(--surface2);
|
||||
border: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.chat-panel .cw-msg.own {
|
||||
margin-left: auto;
|
||||
background: color-mix(in srgb, var(--accent) 11%, var(--surface2));
|
||||
}
|
||||
.chat-panel .cw-msg .cw-who { color: var(--muted); font-size: 0.72rem; margin: 0 0 2px; font-family: var(--font-mono); }
|
||||
.chat-panel footer { padding: 10px; border-top: var(--hairline); display: flex; gap: 8px; }
|
||||
.chat-panel footer input { flex: 1; }
|
||||
|
||||
/* ---- scrollbars ---- */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row { flex-direction: column; gap: 0; }
|
||||
.topnav { flex-wrap: wrap; }
|
||||
h1 { font-size: 1.6rem; }
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -149,6 +149,8 @@ function renderChildren(parent, kids, depth) {
|
||||
</label>
|
||||
<span>
|
||||
<button class="btn small" data-act="edit">Edit</button>
|
||||
${['imported', 'atomized'].includes(k.status)
|
||||
? '<button class="btn small" data-act="subdivide" data-needs-atomizer>Subdivide</button>' : ''}
|
||||
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
|
||||
${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
|
||||
<button class="btn small" data-act="archive">Archive</button>
|
||||
@@ -183,6 +185,8 @@ function renderChildren(parent, kids, depth) {
|
||||
});
|
||||
}
|
||||
row.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(k, false));
|
||||
const sd = row.querySelector('[data-act=subdivide]');
|
||||
if (sd) sd.addEventListener('click', () => openSubdivide(k));
|
||||
row.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(k));
|
||||
row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k));
|
||||
const pub = row.querySelector('[data-act=publish]');
|
||||
|
||||
@@ -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,154 @@
|
||||
// Floating messages bubble (bottom-right, every page except /messages):
|
||||
// unread badge, mini panel with conversation list ↔ thread view and a quick
|
||||
// plain-text composer. Reuses the conversations API + live WS channel.
|
||||
import { api } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
|
||||
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
|
||||
let meId = '';
|
||||
let open = false;
|
||||
let current = null; // conversation object when in thread view
|
||||
const userCache = new Map();
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
const fab = document.createElement('button');
|
||||
fab.className = 'chat-fab';
|
||||
fab.setAttribute('aria-label', 'Messages');
|
||||
fab.innerHTML = '✉<span class="bell-badge" id="cw-unread" hidden></span>';
|
||||
document.body.appendChild(fab);
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'chat-panel';
|
||||
panel.hidden = true;
|
||||
panel.innerHTML = `
|
||||
<header>
|
||||
<span style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<button class="btn small" id="cw-back" hidden aria-label="Back to conversations">← Back</button>
|
||||
<strong id="cw-title">Messages</strong>
|
||||
</span>
|
||||
<span style="display:flex;gap:6px">
|
||||
<a class="btn small" id="cw-full" href="/messages" aria-label="Open full messages" title="Open full messages">⤢</a>
|
||||
<button class="btn small" id="cw-close" aria-label="Close" title="Close">✕</button>
|
||||
</span>
|
||||
</header>
|
||||
<div class="cw-body" id="cw-body"></div>
|
||||
<footer id="cw-footer" hidden>
|
||||
<input type="text" id="cw-input" placeholder="Write a message…" aria-label="Message">
|
||||
<button class="btn small primary" id="cw-send">Send</button>
|
||||
</footer>`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
const body = panel.querySelector('#cw-body');
|
||||
const unreadBadge = fab.querySelector('#cw-unread');
|
||||
|
||||
async function userName(id) {
|
||||
if (!userCache.has(id)) {
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/users/${id}/card`);
|
||||
userCache.set(id, res.card.name);
|
||||
} catch (e) { userCache.set(id, '…'); }
|
||||
}
|
||||
return userCache.get(id);
|
||||
}
|
||||
|
||||
async function refreshUnread() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/conversations');
|
||||
const total = res.conversations.reduce((a, c) => a + (c.unread || 0), 0);
|
||||
unreadBadge.textContent = total > 9 ? '9+' : String(total);
|
||||
unreadBadge.hidden = total === 0;
|
||||
return res.conversations;
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
async function showList() {
|
||||
current = null;
|
||||
panel.querySelector('#cw-back').hidden = true;
|
||||
panel.querySelector('#cw-footer').hidden = true;
|
||||
panel.querySelector('#cw-title').textContent = 'Messages';
|
||||
const convs = await refreshUnread();
|
||||
body.replaceChildren(...convs.map((c) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'cw-conv';
|
||||
b.innerHTML = `<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
|
||||
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}`;
|
||||
b.addEventListener('click', () => showThread(c));
|
||||
return b;
|
||||
}));
|
||||
if (!convs.length) {
|
||||
body.innerHTML = '<p class="muted" style="padding:10px">No conversations yet — start one from the Messages page.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMsg(m) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'cw-msg' + (m.senderId === meId ? ' own' : '');
|
||||
div.innerHTML = `<p class="cw-who">${esc(await userName(m.senderId))}</p><div>${m.body || ''}</div>` +
|
||||
((m.attachments || []).length ? `<p class="muted" style="margin:4px 0 0;font-size:0.75rem">📎 ${m.attachments.length} attachment(s)</p>` : '');
|
||||
return div;
|
||||
}
|
||||
|
||||
async function showThread(c) {
|
||||
current = c;
|
||||
panel.querySelector('#cw-back').hidden = false;
|
||||
panel.querySelector('#cw-footer').hidden = false;
|
||||
panel.querySelector('#cw-title').textContent = c.title;
|
||||
body.replaceChildren();
|
||||
const res = await api('GET', `/api/v1/conversations/${c.id}/messages?limit=30`);
|
||||
for (const m of res.messages) body.appendChild(await renderMsg(m));
|
||||
body.scrollTop = body.scrollHeight;
|
||||
api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {});
|
||||
refreshUnread();
|
||||
panel.querySelector('#cw-input').focus();
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const input = panel.querySelector('#cw-input');
|
||||
const text = input.value.trim();
|
||||
if (!text || !current) return;
|
||||
input.value = '';
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
|
||||
body: '<p>' + esc(text) + '</p>',
|
||||
});
|
||||
} catch (e) { input.value = text; }
|
||||
}
|
||||
panel.querySelector('#cw-send').addEventListener('click', send);
|
||||
panel.querySelector('#cw-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); send(); }
|
||||
});
|
||||
|
||||
fab.addEventListener('click', async () => {
|
||||
open = !open;
|
||||
panel.hidden = !open;
|
||||
if (open) {
|
||||
if (!meId) {
|
||||
try { meId = (await api('GET', '/api/v1/auth/me')).user.id; } catch (e) { /* ignore */ }
|
||||
}
|
||||
showList();
|
||||
}
|
||||
});
|
||||
panel.querySelector('#cw-close').addEventListener('click', () => {
|
||||
open = false;
|
||||
panel.hidden = true;
|
||||
});
|
||||
panel.querySelector('#cw-back').addEventListener('click', showList);
|
||||
|
||||
subscribe('chat', async (event, data) => {
|
||||
if (event !== 'message' || !data) return;
|
||||
if (open && current && data.conversationId === current.id) {
|
||||
body.appendChild(await renderMsg(data));
|
||||
body.scrollTop = body.scrollHeight;
|
||||
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
||||
} else if (open && !current) {
|
||||
showList();
|
||||
} else {
|
||||
refreshUnread();
|
||||
}
|
||||
});
|
||||
onPollFallback(refreshUnread);
|
||||
refreshUnread();
|
||||
}
|
||||
@@ -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,306 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.js';
|
||||
|
||||
const errorBox = document.getElementById('error');
|
||||
const convList = document.getElementById('conv-list');
|
||||
const msgHost = document.getElementById('chat-messages');
|
||||
const form = document.getElementById('chat-form');
|
||||
const composer = document.getElementById('chat-composer');
|
||||
|
||||
let meId = '';
|
||||
let conversations = [];
|
||||
let current = null;
|
||||
let oldestCursor = '';
|
||||
let pendingAttachments = [];
|
||||
const userCache = new Map();
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
async function userName(id) {
|
||||
if (!userCache.has(id)) {
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/users/${id}/card`);
|
||||
userCache.set(id, res.card.name);
|
||||
} catch (e) { userCache.set(id, '…'); }
|
||||
}
|
||||
return userCache.get(id);
|
||||
}
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/conversations');
|
||||
conversations = res.conversations;
|
||||
renderConvList();
|
||||
const want = new URLSearchParams(location.search).get('c');
|
||||
if (!current && want) {
|
||||
const c = conversations.find((x) => x.id === want);
|
||||
if (c) openConversation(c);
|
||||
}
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
function renderConvList() {
|
||||
convList.replaceChildren(...conversations.map((c) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = current && current.id === c.id ? 'active' : '';
|
||||
li.innerHTML = `
|
||||
<button type="button" class="conv-item">
|
||||
<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
|
||||
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}
|
||||
</button>`;
|
||||
li.querySelector('button').addEventListener('click', () => openConversation(c));
|
||||
return li;
|
||||
}));
|
||||
if (!conversations.length) {
|
||||
convList.innerHTML = '<li class="muted">No conversations yet.</li>';
|
||||
}
|
||||
}
|
||||
|
||||
async function openConversation(c) {
|
||||
current = c;
|
||||
oldestCursor = '';
|
||||
document.getElementById('chat-title').textContent = c.title;
|
||||
form.hidden = false;
|
||||
msgHost.replaceChildren();
|
||||
renderConvList();
|
||||
await loadMessages(false);
|
||||
await api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {});
|
||||
c.unread = 0;
|
||||
renderConvList();
|
||||
history.replaceState(null, '', '/messages?c=' + c.id);
|
||||
}
|
||||
|
||||
async function loadMessages(prepend) {
|
||||
const res = await api('GET',
|
||||
`/api/v1/conversations/${current.id}/messages?cursor=${prepend ? oldestCursor : ''}`);
|
||||
const msgs = res.messages;
|
||||
if (msgs.length) oldestCursor = msgs[0].id;
|
||||
const nodes = await Promise.all(msgs.map(renderMessage));
|
||||
if (prepend) msgHost.prepend(...nodes);
|
||||
else {
|
||||
msgHost.append(...nodes);
|
||||
msgHost.scrollTop = msgHost.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
|
||||
function ulidTime(id) {
|
||||
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
let ms = 0;
|
||||
for (const ch of String(id).slice(0, 10).toUpperCase()) ms = ms * 32 + A.indexOf(ch);
|
||||
return new Date(ms);
|
||||
}
|
||||
|
||||
async function renderMessage(m) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg' + (m.senderId === meId ? ' own' : '');
|
||||
const atts = (m.attachments || []).map((a) => a.isImage
|
||||
? `<img src="/files/${a.fileId}" alt="${esc(a.name)}" class="chat-img" data-lightbox loading="lazy">`
|
||||
: `<a class="btn small" href="/files/${a.fileId}">📄 ${esc(a.name)}</a>`).join(' ');
|
||||
div.innerHTML = `
|
||||
<p class="muted" style="margin:0">${esc(await userName(m.senderId))}
|
||||
· ${ulidTime(m.id).toLocaleString()}</p>
|
||||
<div>${m.body || ''}</div>
|
||||
${atts ? `<div class="mt">${atts}</div>` : ''}`;
|
||||
div.querySelectorAll('[data-lightbox]').forEach((img) => {
|
||||
img.addEventListener('click', () => {
|
||||
document.getElementById('lightbox-img').src = img.src;
|
||||
document.getElementById('lightbox').showModal();
|
||||
});
|
||||
});
|
||||
return div;
|
||||
}
|
||||
|
||||
document.getElementById('lightbox').addEventListener('click', () => {
|
||||
document.getElementById('lightbox').close();
|
||||
});
|
||||
|
||||
// ---- composer ----
|
||||
document.querySelectorAll('[data-cmd]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
composer.focus();
|
||||
document.execCommand(btn.dataset.cmd, false, null);
|
||||
});
|
||||
});
|
||||
|
||||
composer.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
let typingTimer = null;
|
||||
composer.addEventListener('input', () => {
|
||||
if (!current) return;
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(() => {
|
||||
wsSend('chat', 'typing', { conversationId: current.id });
|
||||
}, 250);
|
||||
});
|
||||
|
||||
async function uploadFiles(fileList) {
|
||||
for (const file of fileList) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await api('POST', '/api/v1/files', fd);
|
||||
pendingAttachments.push(res);
|
||||
renderPending();
|
||||
} catch (e) { toast(`Upload failed: ${e.message}`, 'err'); }
|
||||
}
|
||||
}
|
||||
|
||||
function renderPending() {
|
||||
const host = document.getElementById('chat-attachments');
|
||||
host.replaceChildren(...pendingAttachments.map((a, i) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge';
|
||||
chip.innerHTML = `${a.isImage ? '🖼' : '📄'} ${esc(a.name)} <button type="button" class="btn small ghost" aria-label="Remove attachment">×</button>`;
|
||||
chip.querySelector('button').addEventListener('click', () => {
|
||||
pendingAttachments.splice(i, 1);
|
||||
renderPending();
|
||||
});
|
||||
return chip;
|
||||
}));
|
||||
}
|
||||
|
||||
document.getElementById('chat-file').addEventListener('change', (e) => {
|
||||
uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
});
|
||||
composer.addEventListener('dragover', (e) => e.preventDefault());
|
||||
composer.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!current) return;
|
||||
const body = composer.innerHTML;
|
||||
if (!composer.textContent.trim() && !pendingAttachments.length) return;
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
|
||||
body, attachments: pendingAttachments.map((a) => a.fileId),
|
||||
});
|
||||
composer.innerHTML = '';
|
||||
pendingAttachments = [];
|
||||
renderPending();
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// ---- live events ----
|
||||
const typingNames = new Map();
|
||||
subscribe('chat', async (event, data) => {
|
||||
if (event === 'message' && data) {
|
||||
if (current && data.conversationId === current.id) {
|
||||
msgHost.appendChild(await renderMessage(data));
|
||||
msgHost.scrollTop = msgHost.scrollHeight;
|
||||
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
||||
} else {
|
||||
loadConversations();
|
||||
}
|
||||
}
|
||||
if (event === 'typing' && data && current && data.conversationId === current.id) {
|
||||
const name = await userName(data.userId);
|
||||
typingNames.set(data.userId, Date.now());
|
||||
document.getElementById('typing-indicator').textContent = `${name} is typing…`;
|
||||
setTimeout(() => {
|
||||
if (Date.now() - (typingNames.get(data.userId) || 0) >= 2900) {
|
||||
document.getElementById('typing-indicator').textContent = '';
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
onPollFallback(() => {
|
||||
loadConversations();
|
||||
if (current) {
|
||||
msgHost.replaceChildren();
|
||||
oldestCursor = '';
|
||||
loadMessages(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- new conversation dialog ----
|
||||
const dlg = document.getElementById('newconv-dialog');
|
||||
const selected = new Map();
|
||||
document.getElementById('conv-new').addEventListener('click', () => {
|
||||
selected.clear();
|
||||
renderSelected();
|
||||
document.getElementById('nc-search').value = '';
|
||||
dlg.showModal();
|
||||
searchPeople(); // pre-fill the fixed-height list so the dialog never jumps
|
||||
});
|
||||
document.getElementById('nc-cancel').addEventListener('click', () => dlg.close());
|
||||
document.getElementById('nc-kind').addEventListener('change', (e) => {
|
||||
document.getElementById('nc-title-wrap').hidden = e.target.value === 'dm';
|
||||
document.getElementById('nc-customer-wrap').hidden = e.target.value !== 'project';
|
||||
});
|
||||
|
||||
let searchTimer = null;
|
||||
async function searchPeople() {
|
||||
const q = document.getElementById('nc-search').value.trim();
|
||||
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
|
||||
const host = document.getElementById('nc-results');
|
||||
const rows = res.users.filter((u) => u.id !== meId && !selected.has(u.id)).map((u) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'nc-row';
|
||||
b.setAttribute('role', 'option');
|
||||
b.textContent = `${u.name} — ${u.email}`;
|
||||
b.addEventListener('click', () => {
|
||||
selected.set(u.id, u);
|
||||
renderSelected();
|
||||
searchPeople(); // refresh list so picked people disappear
|
||||
});
|
||||
return b;
|
||||
});
|
||||
host.replaceChildren(...rows);
|
||||
if (!rows.length) {
|
||||
host.innerHTML = '<p class="nc-empty">No matches — try a name or email.</p>';
|
||||
}
|
||||
}
|
||||
document.getElementById('nc-search').addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(searchPeople, 250);
|
||||
});
|
||||
|
||||
function renderSelected() {
|
||||
const host = document.getElementById('nc-selected');
|
||||
host.replaceChildren(...[...selected.values()].map((u) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge';
|
||||
chip.textContent = u.name + ' ×';
|
||||
chip.style.cursor = 'pointer';
|
||||
chip.addEventListener('click', () => { selected.delete(u.id); renderSelected(); });
|
||||
return chip;
|
||||
}));
|
||||
}
|
||||
|
||||
document.getElementById('newconv-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const kind = document.getElementById('nc-kind').value;
|
||||
const res = await api('POST', '/api/v1/conversations', {
|
||||
kind,
|
||||
title: document.getElementById('nc-title').value.trim(),
|
||||
customerId: document.getElementById('nc-customer').value.trim(),
|
||||
participantIds: [...selected.keys()],
|
||||
});
|
||||
dlg.close();
|
||||
await loadConversations();
|
||||
const conv = conversations.find((c) => c.id === res.conversation.id);
|
||||
if (conv) openConversation(conv);
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
meId = me.user.id;
|
||||
} catch (e) { /* page guard handles */ }
|
||||
loadConversations();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Global keyboard shortcuts (§10): g b → board, g m → messages, / → search.
|
||||
// Plus profile hover cards (§11.13) for any [data-user-card] element.
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
let pendingG = false;
|
||||
let gTimer = null;
|
||||
|
||||
function typingTarget(e) {
|
||||
const t = e.target;
|
||||
return t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (typingTarget(e) || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.key === '/') {
|
||||
const search = document.querySelector('input[type=search]');
|
||||
if (search) { e.preventDefault(); search.focus(); }
|
||||
return;
|
||||
}
|
||||
if (pendingG) {
|
||||
pendingG = false;
|
||||
clearTimeout(gTimer);
|
||||
const map = { b: '/board', m: '/messages', h: '/', t: '/my-tasks', a: '/consultant/board' };
|
||||
if (map[e.key]) { e.preventDefault(); window.location.href = map[e.key]; }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'g') {
|
||||
pendingG = true;
|
||||
gTimer = setTimeout(() => { pendingG = false; }, 1200);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- hover cards ----
|
||||
let cardEl = null;
|
||||
let hideTimer = null;
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
function hideCard() {
|
||||
if (cardEl) { cardEl.remove(); cardEl = null; }
|
||||
}
|
||||
|
||||
async function showCard(target, userId) {
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/users/${userId}/card`);
|
||||
const c = res.card;
|
||||
hideCard();
|
||||
cardEl = document.createElement('div');
|
||||
cardEl.className = 'hovercard card';
|
||||
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]);
|
||||
cardEl.innerHTML = `
|
||||
<div class="spread">
|
||||
${c.avatarFileId ? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
|
||||
: `<span class="avatar large">${esc(c.name.slice(0, 1).toUpperCase())}</span>`}
|
||||
<div>
|
||||
<strong>${esc(c.name)}</strong><br>
|
||||
${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}
|
||||
</div>
|
||||
</div>
|
||||
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 160))}</p>` : ''}
|
||||
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}`;
|
||||
document.body.appendChild(cardEl);
|
||||
const rect = target.getBoundingClientRect();
|
||||
cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px';
|
||||
cardEl.style.top = (rect.bottom + window.scrollY + 6) + 'px';
|
||||
cardEl.addEventListener('mouseenter', () => clearTimeout(hideTimer));
|
||||
cardEl.addEventListener('mouseleave', () => { hideTimer = setTimeout(hideCard, 200); });
|
||||
} catch (e) { /* card is best-effort */ }
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const target = e.target.closest('[data-user-card]');
|
||||
if (!target) return;
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => showCard(target, target.dataset.userCard), 350);
|
||||
});
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
if (e.target.closest('[data-user-card]')) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(hideCard, 250);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -57,7 +57,9 @@
|
||||
</main>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<script type="module" src="/static/js/nav.js"></script>
|
||||
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>{{end}}
|
||||
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>
|
||||
<script type="module" src="/static/js/shortcuts.js"></script>
|
||||
<script type="module" src="/static/js/chat-widget.js"></script>{{end}}
|
||||
{{range .Scripts}}<script type="module" src="{{.}}"></script>
|
||||
{{end}}
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{{define "content"}}
|
||||
<header class="masthead">
|
||||
<p class="masthead-kicker">Consulting Work Exchange</p>
|
||||
<p class="masthead-title">◈ Bounty Board</p>
|
||||
<p class="masthead-rule">❦</p>
|
||||
</header>
|
||||
<div class="card">
|
||||
<h1>Log in</h1>
|
||||
<div class="error-box" id="error" role="alert">{{.Error}}</div>
|
||||
@@ -13,7 +18,7 @@
|
||||
</div>
|
||||
<div class="spread">
|
||||
<button class="btn primary" type="submit">Log in</button>
|
||||
<a href="/register">Create an account</a>
|
||||
<span><a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a></span>
|
||||
</div>
|
||||
</form>
|
||||
{{if .OIDCEnabled}}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{{define "content"}}
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<div class="chat-layout">
|
||||
<aside class="chat-sidebar card">
|
||||
<div class="spread">
|
||||
<h2>Messages</h2>
|
||||
<button class="btn small primary" id="conv-new">+ New</button>
|
||||
</div>
|
||||
<ul id="conv-list" class="conv-list"></ul>
|
||||
</aside>
|
||||
<section class="chat-main card">
|
||||
<div class="spread">
|
||||
<h2 id="chat-title">Select a conversation</h2>
|
||||
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
|
||||
<form id="chat-form" hidden>
|
||||
<div id="chat-attachments" class="chat-attachments"></div>
|
||||
<div class="chat-toolbar" role="toolbar" aria-label="Formatting">
|
||||
<button class="btn small" type="button" data-cmd="bold" aria-label="Bold"><b>B</b></button>
|
||||
<button class="btn small" type="button" data-cmd="italic" aria-label="Italic"><i>I</i></button>
|
||||
<button class="btn small" type="button" data-cmd="underline" aria-label="Underline"><u>U</u></button>
|
||||
<button class="btn small" type="button" data-cmd="strikeThrough" aria-label="Strikethrough"><s>S</s></button>
|
||||
<button class="btn small" type="button" data-cmd="insertUnorderedList" aria-label="Bulleted list">• list</button>
|
||||
<label class="btn small" style="cursor:pointer">📎<input type="file" id="chat-file" multiple hidden></label>
|
||||
</div>
|
||||
<div id="chat-composer" class="chat-composer" contenteditable="true" role="textbox"
|
||||
aria-multiline="true" aria-label="Message" data-placeholder="Write a message… (drag & drop files)"></div>
|
||||
<div class="spread mt">
|
||||
<span class="hint">Enter to send · Shift+Enter for newline</span>
|
||||
<button class="btn primary" type="submit">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog id="newconv-dialog">
|
||||
<form method="dialog" id="newconv-form" class="stack" style="min-width:420px">
|
||||
<h2>New conversation</h2>
|
||||
<div class="field">
|
||||
<label for="nc-kind">Kind</label>
|
||||
<select id="nc-kind">
|
||||
<option value="dm">Direct message</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="project">Project</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="nc-title-wrap" hidden>
|
||||
<label for="nc-title">Title</label>
|
||||
<input type="text" id="nc-title">
|
||||
</div>
|
||||
<div class="field" id="nc-customer-wrap" hidden>
|
||||
<label for="nc-customer">Customer id</label>
|
||||
<input type="text" id="nc-customer" placeholder="paste customer id">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc-search">Participants</label>
|
||||
<input type="search" id="nc-search" placeholder="Search people…">
|
||||
<div id="nc-results" role="listbox" aria-label="Search results"></div>
|
||||
<div id="nc-selected" class="mt"></div>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<button class="btn" type="button" id="nc-cancel">Cancel</button>
|
||||
<button class="btn primary" type="submit">Start conversation</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="lightbox" class="lightbox">
|
||||
<img id="lightbox-img" alt="">
|
||||
</dialog>
|
||||
{{end}}
|
||||
@@ -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,7 +57,7 @@
|
||||
<div class="field">
|
||||
<label for="p-theme">Theme</label>
|
||||
<select id="p-theme">
|
||||
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (beige)</option>
|
||||
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (Y2K paper)</option>
|
||||
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{{define "content"}}
|
||||
<header class="masthead">
|
||||
<p class="masthead-kicker">Consulting Work Exchange</p>
|
||||
<p class="masthead-title">◈ Bounty Board</p>
|
||||
<p class="masthead-rule">❦</p>
|
||||
</header>
|
||||
<div class="card">
|
||||
<h1>Create account</h1>
|
||||
<p class="hint">Self-registration creates a developer account. Consultant and admin roles are granted by an administrator.</p>
|
||||
|
||||
@@ -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