diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 64c84f2..f35cb55 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -51,6 +51,11 @@ jobs: # - gha-curl-pipe-shell: the official Trivy installer over TLS. # - nginx request-host/dynamic-proxy/missing-internal: standard # same-origin reverse proxy; the upstream is an internal config value. + # - nginx possible-h2c-smuggling: purely-syntactic rule that fires on + # any WebSocket proxy (proxy_http_version 1.1 + Upgrade + Connection + # together). The /hubs SignalR proxy needs those; the actual h2c + # vector is mitigated by the $atlas_ws_upgrade/$atlas_ws_connection + # maps that clear both headers for non-`websocket` Upgrade tokens. # - dockerfile missing-user: nginx edge master binds 80/443 (see # .trivyignore.yaml). semgrep scan --error \ @@ -62,6 +67,7 @@ jobs: --exclude-rule generic.nginx.security.request-host-used.request-host-used \ --exclude-rule generic.nginx.security.dynamic-proxy-host.dynamic-proxy-host \ --exclude-rule generic.nginx.security.missing-internal.missing-internal \ + --exclude-rule generic.nginx.security.possible-h2c-smuggling.possible-nginx-h2c-smuggling \ --exclude-rule dockerfile.security.missing-user.missing-user \ --metrics off \ --oss-only \ diff --git a/README.md b/README.md index 62d2cb6..59cfcb4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ prototype. Setup & ops guides live in [`docs/`](./docs/) (setup, docker, sso, secrets, observability, retention, security hardening, Jira, [notification email via -Microsoft Graph](./docs/email-graph-setup.md)). The **architecture reference** +Microsoft Graph](./docs/email-graph-setup.md), [Teams channel +notifications](./docs/teams-setup.md)). The **architecture reference** — High Level Design, Low Level Design, ABB/SBB catalogue and ADRs, with diagrams — is in [`docs/architecture/`](./docs/architecture/). diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 21dff4e..69fae42 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -3,6 +3,23 @@ # at /etc/nginx/certs (see docs/SETUP.md). HTTP is redirected to HTTPS — # required because Entra only accepts HTTPS redirect URIs off localhost. +# WebSocket upgrade for the SignalR hub, constrained to the `websocket` value +# only. Both the Upgrade and Connection headers are derived from a map instead +# of forwarding the raw client Upgrade token: a genuine WebSocket request gets +# `Upgrade: websocket` + `Connection: upgrade`, and anything else (SignalR's +# SSE/long-poll fallbacks, or a smuggled `Upgrade: h2c`) gets both headers +# cleared. That prevents an HTTP/2-cleartext upgrade from being smuggled past +# this reverse proxy to the backend (Semgrep possible-nginx-h2c-smuggling; see +# docs/security-hardening.md). +map $http_upgrade $atlas_ws_upgrade { + default ""; + websocket "websocket"; +} +map $http_upgrade $atlas_ws_connection { + default ""; + websocket "upgrade"; +} + # HTTP -> HTTPS redirect. server { listen 80; @@ -58,6 +75,22 @@ server { proxy_read_timeout 60s; } + # PI Program Board SignalR hub — same-origin reverse proxy with WebSocket + # upgrade. Long read timeout so idle sockets aren't dropped mid-session. + location /hubs/ { + resolver 127.0.0.11 ipv6=off valid=10s; + set $atlas_hub "api:8080"; + proxy_pass http://$atlas_hub$request_uri; + proxy_http_version 1.1; + proxy_set_header Upgrade $atlas_ws_upgrade; + proxy_set_header Connection $atlas_ws_connection; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + } + # SPA history fallback — every other route serves index.html. location / { try_files $uri $uri/ /index.html; diff --git a/docs/architecture/adr/0060-teams-notification-channel.md b/docs/architecture/adr/0060-teams-notification-channel.md new file mode 100644 index 0000000..a9db7c7 --- /dev/null +++ b/docs/architecture/adr/0060-teams-notification-channel.md @@ -0,0 +1,64 @@ +# ADR-0060 — Microsoft Teams as a third notification channel + +**Status:** Accepted — extends the notification system +([ADR-0028](./0028-over-allocation-alerts.md) alerts, +[ADR-0045](./0045-per-role-demand-email.md) per-role email) and the connector +pattern ([ADR-0006](./0006-jira-pull-only-board-optional.md), +[ADR-0035](./0035-azure-devops-connector.md)). + +## Context +Atlas already emits notifications on three paths — entity events (a subscribed +project/program/product changes), portfolio events ("created"), and +role-addressed governance events (a demand pinging the PMO/Architect/CTO). Each +is delivered **in-app** and, when Microsoft Graph Mail.Send is configured, by +**email**. The prototype's Integrations screen lists **Microsoft Teams** as a +connector ("Channel & chat notifications"), but it was cosmetic chrome. Teams is +where the org actually works, so notifications should be able to land there too — +without inventing a new UI or a new delivery contract. + +## Decision +Add **Teams as a third channel** to the *existing* emit paths, not a parallel +system. `Notifications.{EmitToEntityAsync, EmitPortfolioAsync, EmitToRolesAsync}` +each call `TeamsNotify.EmitAsync(db, title, body)` after persisting the in-app +copy and sending email, so any event already produced is mirrored to a Teams +channel with the same title/body. + +**Delivery** is an **Incoming Webhook** — created in Teams via *Workflows → "Post +to a channel when a webhook request is received"*, the forward-looking +replacement for the retired O365 connectors. Atlas POSTs an **Adaptive Card** +(the `type:"message"` + `attachments[]` envelope Workflows expects). Best-effort: +the in-app copy is always written first, so an unconfigured connector or a Teams +outage degrades silently rather than failing the originating action — identical +to the email contract. + +**Configuration** lives in the `Setting` key/value store (`teams.webhookUrl`, +`teams.enabled`), reachable only through **`cap-integrations`-gated** endpoints +(`GET /integrations/teams/status`, `POST …/config`, `POST …/test`). The webhook +URL is a channel secret, so — unlike the generic `GET /settings` — it is **never +returned to the client**: status exposes only a masked `scheme://host` and the +`configured`/`enabled` booleans. The Integrations screen's Teams row becomes a +live connector (Configure modal + Send-test), mirroring the Jira/ADO rows. + +## Consequences +- **+** Notifications reach Teams with zero new event plumbing and no new Graph + permissions/consent (webhook is channel-scoped, not tenant-wide). +- **+** Fully degradable and opt-in: idle until an admin pastes a URL and enables + it; muting keeps the URL but stops delivery. +- **+** Secret-safe: the URL is write-only over the API and kept out of the + broadly-readable `/settings` payload. +- **−** A single channel per instance (one webhook). Per-event or per-team routing + is a candidate follow-up, not this ADR. +- **−** Outbound-only. Two-way (actionable cards, replies) would need a Teams app + / bot registration and is out of scope. + +## Alternatives considered +- **Graph channel messages** (`POST /teams/{id}/channels/{id}/messages`) — richer, + but needs `ChannelMessage.Send` application permission (protected API, heavier + consent/licensing) for app-only posting. Rejected for the default path; the + webhook is the lowest-friction fit and reuses the connector mental model. +- **Legacy O365 "Incoming Webhook" connector** (MessageCard) — simplest, but + Microsoft is retiring O365 connectors; targeting the Workflows Adaptive-Card + envelope is future-proof. +- **A separate Teams preference per event type** — deferred; the channel rides the + existing in-app/email preferences and subscriptions rather than adding a third + column to every preference row now. diff --git a/docs/architecture/adr/0061-realtime-pi-program-board.md b/docs/architecture/adr/0061-realtime-pi-program-board.md new file mode 100644 index 0000000..720296d --- /dev/null +++ b/docs/architecture/adr/0061-realtime-pi-program-board.md @@ -0,0 +1,87 @@ +# ADR-0061 — Real-time PI Program Board (SignalR) + +**Status:** Accepted — extends PI Planning +([Pip endpoints], the increment/objective/dependency model) and the +code-splitting policy ([ADR-0027](./0027-frontend-code-splitting.md)). + +## Context +PI Planning already stored the SAFe model — `ProgramIncrement` → iterations, +`PiObjective` (business value + confidence), and `PiDependency` (deliverable→ +deliverable links). The Dependencies tab rendered them as a **list**. Planners +run PI planning as a shared, synchronous ritual (the SAFe "program board"), for +which teams typically leave Atlas for a whiteboard tool. We wanted that ritual +*in* Atlas: a live swimlane board — deliverables as lanes, iterations as +columns, objectives as draggable cards, dependencies as arrows — that several +people can work at once. + +## Decision +Add a **Program Board** view to the existing PI Planning screen (not a new +module — the data and screen already exist) plus a real-time layer. + +**Layout (reuses existing data).** Rows are derived from each objective's linked +deliverable (`EntityType/EntityId`); columns are the increment's iterations. +Only the *column* placement is board-specific state, persisted as a JSON map +(`objectiveId → iterationId`) in the existing `Setting` store under +`pi.board.{incrementId}` — **migration-free** (the build environment can't +generate an EF migration). It's presentation state, not a domain fact; +promoting it to a first-class `PiObjective.IterationId` column is a clean +follow-up. Dependency arrows are drawn between lanes from the existing +`PiDependency` links. + +**Real-time transport.** A SignalR hub (`/hubs/board`, `BoardHub`) provides +**presence**, **peer cursors**, and a **change ping**. Crucially, *no domain +data travels over the hub* — all reads/writes still go through the REST API +(`cap-schedule`), and the ping is contentless ("refetch"). Clients are grouped +per increment (`pi:{id}`). Placement moves and dependency links call REST, then +emit the ping so peers refetch the authoritative state (notify-and-refetch — no +CRDT). The client is lazy-loaded so the SignalR bundle only loads with the board. + +## Security & compliance +Designed against the platform's control baseline +([ADR-0049](./0049-compliance-coverage-and-zero-trust.md)): + +- **Authentication / access control (ISO 27001 A.9, A.5.15; NIST AC-3, IA-2).** + The hub is mapped with `RequireAuthorization()` whenever `Auth:Enabled`, in + lock-step with the API. Browsers can't set an `Authorization` header on the + WebSocket handshake, so the client passes the Entra bearer as an `access_token` + query value; JwtBearer is configured to read it **only** for `/hubs` paths. +- **Least privilege / no privilege escalation (NIST AC-6).** The hub carries no + mutations — it cannot change portfolio data. Every write remains behind the + `cap-schedule` REST checks and the existing audit log, so a socket can never do + more than the caller's REST permissions already allow. +- **Segregation (NIST SC-7; ISO A.8.22).** Per-increment groups mean a client + only receives events for the board it explicitly joined — no cross-board leakage. +- **Data minimisation & storage limitation (GDPR Art. 5(1)(c),(e)).** Presence + broadcasts a display name, initials and a colour derived from the opaque + connection id — **no email or stable user identifier**. Cursors are normalised + coordinates. None of it is persisted: presence/cursor state lives only in + process memory for the life of a connection and is dropped on disconnect. +- **Availability / resource abuse (NIST SC-5).** Cursor messages are + client-throttled (~16/s) and server-clamped; the connection auto-reconnects and + the board degrades to non-realtime if the hub is unreachable. +- **Transport security (ISO A.8.24).** Same-origin behind the TLS edge; nginx + proxies `/hubs` with the WebSocket upgrade (`docs` + `deploy/nginx.conf`). + +## Consequences +- **+** PI planning becomes a live, multi-user board without leaving Atlas, on + top of data that already existed. +- **+** Small, auditable security surface: presence/cursor relay only; the DB + stays the single source of truth via REST. +- **+** Migration-free — ships in an environment that can't run `dotnet ef`. +- **−** Placement lives in a key/value blob rather than a typed column (promotion + is a follow-up); board layout also appears in the broad `GET /settings` dump + (non-sensitive). +- **−** Notify-and-refetch is coarser than field-level co-editing; sufficient for + planning cadence, and a CRDT/OT upgrade remains open if needed. +- **−** The .NET pieces were written to the codebase's patterns but **not compiled + here** (no SDK); a `dotnet build`/`test` gate on CI is the real confirmation. + +## Alternatives considered +- **New top-level "Board" module** — rejected: duplicates the PI model and adds a + screen outside the approved prototype. +- **Poll the REST API for liveness** — rejected: no presence/cursors, and either + laggy or wasteful; SignalR is the right transport for a synchronous ritual. +- **Full CRDT co-editing** — deferred: heavier and unnecessary for card placement + and dependency links at planning cadence. +- **First-class `IterationId` column now** — deferred until a migration can be + generated; the `Setting`-backed map is the migration-free interim. diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index a628745..a164b35 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -66,5 +66,7 @@ ADRs are immutable once *Accepted*; to change a decision, add a new ADR that | [0057](./0057-region-scoped-labour-rates.md) | Region-scoped labour rate lines + regional manager roles (APAC/BLOG) | Accepted | | [0058](./0058-five-year-calendar-timeline-window.md) | Calendar timeline window (up to 5 years) on an absolute-month model | Accepted | | [0059](./0059-task-lifecycle-timeline.md) | Task lifecycle timeline + Jira changelog-derived started/resolved timestamps | Accepted | +| [0060](./0060-teams-notification-channel.md) | Microsoft Teams as a third notification channel (channel webhook + Adaptive Card) | Accepted | +| [0061](./0061-realtime-pi-program-board.md) | Real-time PI Program Board (SignalR presence/cursors + notify-and-refetch) | Accepted | Template: Context · Decision · Consequences · Alternatives considered. diff --git a/docs/security-hardening.md b/docs/security-hardening.md index 4476bf7..cb6782b 100644 --- a/docs/security-hardening.md +++ b/docs/security-hardening.md @@ -134,6 +134,7 @@ blanket disable: | `mutable-action-tag` | Semgrep `--exclude-rule` | Actions pinned to major versions, kept current by Dependabot's `github-actions` ecosystem; SHA-pinning deferred. | | `gha-curl-pipe-shell` | Semgrep `--exclude-rule` | Official Trivy installer over TLS from the vendor repo. | | nginx `request-host` / `dynamic-proxy-host` / `missing-internal` | Semgrep `--exclude-rule` | Standard same-origin reverse proxy; the `proxy_pass` upstream is an internal config value, not attacker input. | +| nginx `possible-h2c-smuggling` | Semgrep `--exclude-rule` | Purely-syntactic rule that fires on any WebSocket proxy (`proxy_http_version 1.1` + `Upgrade` + `Connection` together), which the `/hubs` SignalR proxy requires. The actual h2c vector **is** mitigated: `$atlas_ws_upgrade`/`$atlas_ws_connection` maps emit `websocket`/`upgrade` only for a genuine WebSocket request and clear both headers for any other Upgrade token (incl. `h2c`), so a cleartext upgrade can't be smuggled to the backend (ADR-0061). | | `design/` (prototype reference) | `.semgrepignore` | The approved prototype (CLAUDE.md §2) — never bundled or served, so its demo helpers aren't application AppSec. | ### CodeQL (GitHub-native, complementary) diff --git a/docs/teams-setup.md b/docs/teams-setup.md new file mode 100644 index 0000000..f6f6d03 --- /dev/null +++ b/docs/teams-setup.md @@ -0,0 +1,54 @@ +# Microsoft Teams notifications + +Atlas can post every notification it raises (project/program/product events, +new-item alerts, and role-addressed governance events) to a **Microsoft Teams +channel** as an Adaptive Card — in addition to the in-app inbox and email. + +This is **outbound-only** and **best-effort**: the in-app copy is always written +first, so if Teams is unreachable or unconfigured nothing else breaks. See +[ADR-0060](./architecture/adr/0060-teams-notification-channel.md). + +## 1. Create a channel webhook (Workflows) + +Microsoft has retired the classic *Office 365 connectors*; the current mechanism +is a **Workflow** with an HTTP trigger: + +1. In Teams, open the target channel → **⋯ → Workflows**. +2. Choose the template **“Post to a channel when a webhook request is + received”** and complete the short wizard (pick the team + channel). +3. Copy the generated **HTTP POST URL**. This URL is a **secret** — anyone with + it can post to the channel. + +## 2. Connect Atlas + +1. Sign in as a user with **Edit** on *Integrations & connectors* (Platform + Admin / PMO). +2. Go to **Integrations & Settings → Connected tools → Microsoft Teams → + Configure**. +3. Paste the webhook URL, tick **Deliver notifications to this channel**, and + **Save**. +4. Click **Send test** — a card should appear in the channel within a few + seconds. + +The webhook URL is stored server-side and is **never** shown again (the status +API returns only the host, e.g. `https://prod-12.westeurope.logic.azure.com`). +To rotate it, paste a new URL over the old one; to stop delivery without losing +the URL, untick the toggle; to remove it entirely, use **Disconnect**. + +## 3. What gets posted + +Every notification Atlas already emits — the same title and body as the in-app +and email copies. A card carries an **Atlas PPM** header plus the event title and +detail. Delivery rides the existing subscriptions and preferences; there is no +separate per-event Teams setting (a candidate follow-up). + +## Troubleshooting + +| Symptom | Likely cause | +| --- | --- | +| **Send test** returns an error with a 4xx | The webhook URL is wrong/expired, or the Workflow was deleted — recreate it and re-paste. | +| Test succeeds but events don't post | The **Deliver notifications** toggle is off (status shows *Muted*), or no one is subscribed to the item that changed. | +| Nothing at all, no error | The connector isn't configured — the Teams row shows *Not connected*. | + +Delivery failures are logged under `Atlas.TeamsNotify` (they never fail the +originating request). diff --git a/package-lock.json b/package-lock.json index 7e426c6..2f186b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@azure/msal-browser": "^5.16.0", "@azure/msal-react": "^5.5.1", + "@microsoft/signalr": "^10.0.0", "@tanstack/react-query": "^5.28.0", "pptxgenjs": "^3.12.0", "react": "^18.2.0", @@ -843,6 +844,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1748,6 +1762,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2341,6 +2367,24 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2390,6 +2434,31 @@ } } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fetch-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3170,6 +3239,48 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -3430,16 +3541,33 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -3553,6 +3681,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", @@ -3625,6 +3759,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -3893,6 +4033,15 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3934,6 +4083,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4232,6 +4391,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index bc65711..e0b9b51 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@azure/msal-browser": "^5.16.0", "@azure/msal-react": "^5.5.1", + "@microsoft/signalr": "^10.0.0", "@tanstack/react-query": "^5.28.0", "pptxgenjs": "^3.12.0", "react": "^18.2.0", diff --git a/server/BoardHub.cs b/server/BoardHub.cs new file mode 100644 index 0000000..a7e9e58 --- /dev/null +++ b/server/BoardHub.cs @@ -0,0 +1,128 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.SignalR; + +namespace Atlas.Api; + +// ============================================================================ +// Real-time PI Program Board (ADR-0061). A SignalR hub that gives the board +// live presence, shared cursors and change-notifications so multiple planners +// can work the same increment together. +// +// Design & security: +// - The board's data is owned by the REST API (PiBoard/Pip endpoints, which +// enforce cap-schedule). This hub carries NO domain writes — it only relays +// presence/cursors and a "something changed, refetch" ping. That keeps the +// authorization surface small: there is no way to mutate portfolio data +// through the hub, so a socket can't escalate past what REST already allows. +// - Access mirrors the API: the hub is mapped with RequireAuthorization when +// Auth:Enabled, so only authenticated principals can connect (Program.cs +// also teaches JwtBearer to read the access_token query string that browsers +// must use for the WebSocket handshake). +// - Traffic is scoped to a per-increment group ("pi:{id}"), so a client only +// ever receives events for the board it explicitly joined — no cross-board +// leakage. +// - Data minimisation (GDPR): presence is display-name + initials + a colour +// derived from the connection id. No email or stable user id is broadcast, +// and nothing here is persisted — presence/cursor state lives only in memory +// for the life of the connection and is dropped on disconnect. +// ============================================================================ +public class BoardHub : Hub +{ + // Lightweight, non-PII presence broadcast to peers on a board. + public record Peer(string Id, string Name, string Initials, string Color); + + // incrementId → (connectionId → peer). In-memory only; a process restart + // simply re-derives it as clients reconnect. + static readonly ConcurrentDictionary> Boards = new(); + + // The connection's current board, so OnDisconnected can clean up without the + // client having to tell us which board it left. + int? CurrentBoard + { + get => Context.Items.TryGetValue("pi", out var v) && v is int i ? i : null; + set { if (value is null) Context.Items.Remove("pi"); else Context.Items["pi"] = value; } + } + + static string Group(int incrementId) => $"pi:{incrementId}"; + + // A stable, pleasant colour per connection (no identity leak — derived from + // the opaque connection id, not from the user). + static readonly string[] Palette = + { "#5B7CFA", "#00A88F", "#E0A100", "#E05252", "#8B5CF6", "#0E9F9F", "#D9488B", "#5A9E4B" }; + static string ColorFor(string connectionId) + { + int h = 0; + foreach (var c in connectionId) h = (h * 31 + c) & 0x7fffffff; + return Palette[h % Palette.Length]; + } + + static string InitialsOf(string name) + { + var parts = (name ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return "··"; + var first = parts[0][..1]; + var last = parts.Length > 1 ? parts[^1][..1] : ""; + return (first + last).ToUpperInvariant(); + } + + // Join a board: register presence and hand the caller the current roster, + // then tell peers someone arrived. `name` is the caller's display name; it is + // cosmetic (identity is still enforced by the API), so we cap its length and + // never trust it for authorization. + public async Task JoinBoard(int incrementId, string? name) + { + var display = string.IsNullOrWhiteSpace(name) ? "Planner" : name.Trim(); + if (display.Length > 60) display = display[..60]; + + // Leave any previous board first (a client that switches increments). + if (CurrentBoard is { } prev && prev != incrementId) await LeaveBoard(prev); + + await Groups.AddToGroupAsync(Context.ConnectionId, Group(incrementId)); + CurrentBoard = incrementId; + + var peer = new Peer(Context.ConnectionId, display, InitialsOf(display), ColorFor(Context.ConnectionId)); + var board = Boards.GetOrAdd(incrementId, _ => new()); + board[Context.ConnectionId] = peer; + + // Send the joiner the full roster; tell everyone else just the newcomer. + await Clients.Caller.SendAsync("Presence", board.Values.ToArray()); + await Clients.OthersInGroup(Group(incrementId)).SendAsync("PeerJoined", peer); + } + + public async Task LeaveBoard(int incrementId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, Group(incrementId)); + if (Boards.TryGetValue(incrementId, out var board) && board.TryRemove(Context.ConnectionId, out _)) + { + if (board.IsEmpty) Boards.TryRemove(incrementId, out _); + await Clients.OthersInGroup(Group(incrementId)).SendAsync("PeerLeft", Context.ConnectionId); + } + if (CurrentBoard == incrementId) CurrentBoard = null; + } + + // Relay a cursor position to peers. Coordinates are normalised [0,1] fractions + // of the board surface (resolution-independent); we clamp defensively and drop + // anything for a board the caller hasn't joined. Ephemeral — never stored. + public async Task Cursor(int incrementId, double x, double y) + { + if (CurrentBoard != incrementId) return; + static double Clamp(double v) => v < 0 ? 0 : v > 1 ? 1 : v; + await Clients.OthersInGroup(Group(incrementId)) + .SendAsync("Cursor", Context.ConnectionId, Clamp(x), Clamp(y)); + } + + // A peer changed the board via REST (moved a card, edited a dependency, …). + // Relay a contentless ping so peers refetch from the authoritative API. No + // data rides the hub, so this can't be used to inject state. + public async Task NotifyChanged(int incrementId) + { + if (CurrentBoard != incrementId) return; + await Clients.OthersInGroup(Group(incrementId)).SendAsync("BoardChanged"); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + if (CurrentBoard is { } id) await LeaveBoard(id); + await base.OnDisconnectedAsync(exception); + } +} diff --git a/server/Endpoints.cs b/server/Endpoints.cs index 079ed0f..53c636f 100644 --- a/server/Endpoints.cs +++ b/server/Endpoints.cs @@ -47,6 +47,7 @@ public static void MapAtlasEndpoints(this WebApplication app) api.MapSubTeamEndpoints(); api.MapJiraEndpoints(); api.MapAzureDevOpsEndpoints(); + api.MapTeamsConnectorEndpoints(); api.MapNotificationEndpoints(); api.MapHelpEndpoints(); api.MapGdprEndpoints(); @@ -55,6 +56,7 @@ public static void MapAtlasEndpoints(this WebApplication app) api.MapCommentEndpoints(); api.MapStakeholderEndpoints(); api.MapPipEndpoints(); + api.MapPiBoardEndpoints(); api.MapResourceEndpoints(); api.MapAvailabilityEndpoints(); api.MapAllocationReportEndpoints(); diff --git a/server/Notifications.cs b/server/Notifications.cs index 3959e8d..7df64dd 100644 --- a/server/Notifications.cs +++ b/server/Notifications.cs @@ -69,6 +69,7 @@ public static async Task EmitToEntityAsync(AtlasDbContext db, IConfiguration cfg } await db.SaveChangesAsync(); await SendEmailsAsync(cfg, emails, title, body); + await TeamsNotify.EmitAsync(db, title, body); } // Deliver a portfolio event (e.g. created) to users who opted into it. @@ -88,6 +89,7 @@ public static async Task EmitPortfolioAsync(AtlasDbContext db, IConfiguration cf } await db.SaveChangesAsync(); await SendEmailsAsync(cfg, emails, title, body); + await TeamsNotify.EmitAsync(db, title, body); } // A notification addressed to a role rather than a person, so everyone @@ -118,6 +120,7 @@ public static async Task EmitToRolesAsync(AtlasDbContext db, IConfiguration cfg, db.Notifications.Add(NewRow(RolePrefix + k, ev, title, body, targetType, targetId)); await db.SaveChangesAsync(); await SendRoleEmailsAsync(db, cfg, ev, targets, title, body); + await TeamsNotify.EmitAsync(db, title, body); } // Resolve the people in the target roles (via the in-app group→role mapping) diff --git a/server/PiBoard.cs b/server/PiBoard.cs new file mode 100644 index 0000000..4fed157 --- /dev/null +++ b/server/PiBoard.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; + +namespace Atlas.Api; + +public record SetPlacementReq(int ObjectiveId, int? IterationId); + +// ============================================================================ +// PI Program Board placement (ADR-0061). The board lays PI objectives out on a +// swimlane grid: rows are the linked deliverables (project/program/product/ +// release) already on each objective, columns are the increment's iterations. +// The row is derived from existing data; only the *column* (which iteration a +// card sits in) is board-specific placement state. +// +// That placement is stored as a small JSON map (objectiveId → iterationId) in +// the existing Setting store under "pi.board.{incrementId}" — deliberately +// migration-free (this environment can't generate an EF migration). It is +// presentation state, not a domain fact, so a key/value home is appropriate; +// promoting it to a first-class PiObjective.IterationId column is a clean +// follow-up once a migration can be generated. Reads are open to any +// authenticated caller (parity with the other /increments reads); writing a +// placement needs Edit on "Project schedule" (cap-schedule), exactly like the +// objective/dependency writes it sits beside. +// ============================================================================ +public static class PiBoard +{ + static string Key(int incrementId) => $"pi.board.{incrementId}"; + + static async Task> LoadAsync(AtlasDbContext db, int incrementId) + { + var raw = (await db.Settings.FindAsync(Key(incrementId)))?.Value; + if (string.IsNullOrWhiteSpace(raw)) return new(); + try { return JsonSerializer.Deserialize>(raw) ?? new(); } + catch { return new(); } // tolerate a hand-edited/corrupt value — treat as empty + } + + static async Task SaveAsync(AtlasDbContext db, int incrementId, Dictionary map) + { + var key = Key(incrementId); + var json = JsonSerializer.Serialize(map); + var s = await db.Settings.FindAsync(key); + if (s is null) db.Settings.Add(new Setting { Key = key, Value = json }); + else s.Value = json; + } + + public static void MapPiBoardEndpoints(this RouteGroupBuilder api) + { + // Current placement map for a board. Prunes entries whose objective or + // iteration no longer exists so callers never see dangling placements. + api.MapGet("/increments/{id:int}/board", async (int id, AtlasDbContext db, IConfiguration cfg, HttpContext http) => + { + if (!await db.ProgramIncrements.AnyAsync(i => i.Id == id)) return Results.NotFound(); + var canEdit = await Permissions.Allows(http, db, cfg, "cap-schedule", "E"); + + var map = await LoadAsync(db, id); + var objIds = await db.PiObjectives.Where(o => o.IncrementId == id).Select(o => o.Id).ToListAsync(); + var iterIds = await db.PiIterations.Where(t => t.IncrementId == id).Select(t => t.Id).ToHashSetAsync(); + var placements = map + .Where(kv => int.TryParse(kv.Key, out var oid) && objIds.Contains(oid) && iterIds.Contains(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + return Results.Ok(new { canEdit, placements }); + }); + + // Place an objective in an iteration column (or clear it → null moves the + // card back to the "Unscheduled" column). Validates that both the + // objective and the iteration belong to this increment before persisting. + api.MapPut("/increments/{id:int}/board/placement", async (int id, SetPlacementReq req, AtlasDbContext db, IConfiguration cfg, HttpContext http) => + { + if (await Permissions.Deny(http, db, cfg, "cap-schedule", "E") is { } denied) return denied; + if (!await db.ProgramIncrements.AnyAsync(i => i.Id == id)) return Results.NotFound(); + + var obj = await db.PiObjectives.FirstOrDefaultAsync(o => o.Id == req.ObjectiveId && o.IncrementId == id); + if (obj is null) return Results.BadRequest(new { error = "That objective isn't in this increment." }); + if (req.IterationId is { } it && !await db.PiIterations.AnyAsync(t => t.Id == it && t.IncrementId == id)) + return Results.BadRequest(new { error = "That iteration isn't in this increment." }); + + var map = await LoadAsync(db, id); + if (req.IterationId is { } iter) map[req.ObjectiveId.ToString()] = iter; + else map.Remove(req.ObjectiveId.ToString()); + await SaveAsync(db, id, map); + + db.AuditEvents.Add(Permissions.Audit(http, cfg, "PI Planning", "Moved board card", + $"objective {req.ObjectiveId} → {(req.IterationId?.ToString() ?? "unscheduled")}")); + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + } +} diff --git a/server/Program.cs b/server/Program.cs index 6f511a0..48c9bbd 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -117,10 +117,32 @@ { o.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; o.TokenValidationParameters.ValidAudiences = validAudiences; + // Browsers can't set an Authorization header on the WebSocket + // handshake, so the SignalR client passes the bearer token as an + // access_token query-string value on the hub URL. Accept it only for + // the hub path — everything else still requires the header (ADR-0061). + o.Events = new JwtBearerEvents + { + OnMessageReceived = ctx => + { + var accessToken = ctx.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken) && + ctx.HttpContext.Request.Path.StartsWithSegments("/hubs")) + ctx.Token = accessToken; + return Task.CompletedTask; + }, + }; }); builder.Services.AddAuthorization(); } +// Real-time transport for the PI Program Board (presence, cursors, change +// pings). No domain writes ride the hub — see BoardHub / ADR-0061. Force +// camelCase payloads so the hub's Peer record matches the TS client's fields +// regardless of the SignalR default. +builder.Services.AddSignalR().AddJsonProtocol(o => + o.PayloadSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase); + var app = builder.Build(); var startupLog = app.Services.GetRequiredService().CreateLogger("Atlas.Startup"); startupLog.LogInformation("Atlas {Role} starting — auth {AuthMode}", @@ -132,6 +154,7 @@ () => app.Services.GetRequiredService().Pending, () => app.Services.GetRequiredService().Pending); Teams.UseLogger(app.Services.GetRequiredService()); +TeamsNotify.UseLogger(app.Services.GetRequiredService()); // Apply migrations on startup. The web/all role owns the schema (a single // migrator avoids two containers racing Migrate()); the worker role skips this @@ -211,7 +234,13 @@ await db.Database.CanConnectAsync() // The API surface is served only by the web/all role; the worker exposes just // the liveness/readiness probes above (for orchestrator health checks). if (runWeb) +{ app.MapAtlasEndpoints(); + // PI Program Board real-time hub. Same-origin (proxied at /hubs by nginx); + // requires auth in lock-step with the API when Auth:Enabled. + var board = app.MapHub("/hubs/board"); + if (authEnabled) board.RequireAuthorization(); +} startupLog.LogInformation("Atlas {Role} ready.", role); app.Run(); diff --git a/server/TeamsNotify.cs b/server/TeamsNotify.cs new file mode 100644 index 0000000..4f46513 --- /dev/null +++ b/server/TeamsNotify.cs @@ -0,0 +1,176 @@ +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Atlas.Api; + +public record TeamsConfigReq(string? WebhookUrl, bool? Enabled); + +// ============================================================================ +// Microsoft Teams — a third notification channel alongside in-app and email +// (ADR-0060). Every notification Atlas already emits (entity, portfolio and +// role-addressed governance events) is ALSO posted to a Teams channel as an +// Adaptive Card when a channel webhook is configured and the channel is +// enabled. Best-effort and self-contained: the in-app copy is always written +// first (see Notifications), so a Teams outage or an unconfigured connector +// degrades silently rather than failing the originating action. +// +// Delivery is via an *Incoming Webhook* URL — created in Teams with the +// "Workflows → Post to a channel when a webhook request is received" template +// (the forward-looking replacement for the retired O365 connectors). The URL +// is a channel secret, so it is stored in the Setting table and NEVER returned +// to the client: the status endpoint exposes only a masked host + a boolean. +// See docs/teams-setup.md. +// ============================================================================ +public static class TeamsNotify +{ + // Operational logger — set once at startup so the static post path can report + // delivery failures / degradation. No-op until wired in Program.cs. + static ILogger _log = NullLogger.Instance; + public static void UseLogger(ILoggerFactory factory) => _log = factory.CreateLogger("Atlas.TeamsNotify"); + + // Setting keys. WebhookUrl is the channel secret; Enabled gates delivery so a + // channel can be muted without discarding its URL. + public const string WebhookKey = "teams.webhookUrl"; + public const string EnabledKey = "teams.enabled"; + + static async Task GetSettingAsync(AtlasDbContext db, string key) => + (await db.Settings.FindAsync(key))?.Value ?? ""; + + // Post an event to the configured Teams channel. Reads the webhook + enabled + // toggle from settings each call (notifications are low-frequency, so the two + // reads are cheap and always current). Silent no-op when unconfigured/disabled. + public static async Task EmitAsync(AtlasDbContext db, string title, string body) + { + var enabled = (await GetSettingAsync(db, EnabledKey)) == "true"; + var url = await GetSettingAsync(db, WebhookKey); + if (!enabled || string.IsNullOrWhiteSpace(url)) return; + await PostAsync(url, title, body); + } + + // Post a single Adaptive Card to a Teams incoming-webhook URL. Best-effort: + // logs and swallows failures (the caller has already persisted the in-app + // copy). Mirrors the degrade-don't-fail contract of the email path. + public static async Task<(bool Ok, string? Error)> PostAsync(string webhookUrl, string title, string body) + { + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + // Built inline (not via a helper returning `object`) so the anonymous + // type — not a bare `object` root — is what gets serialised. + var payload = new + { + type = "message", + attachments = new[] + { + new + { + contentType = "application/vnd.microsoft.card.adaptive", + content = new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new { type = "TextBlock", size = "Small", weight = "Bolder", color = "Accent", text = "Atlas PPM", wrap = true }, + new { type = "TextBlock", size = "Medium", weight = "Bolder", text = title, wrap = true, spacing = "None" }, + new { type = "TextBlock", text = body, wrap = true }, + }, + }, + }, + }, + }; + var res = await http.PostAsJsonAsync(webhookUrl, payload); + if (res.IsSuccessStatusCode) + { + _log.LogInformation("Teams notification posted for “{Title}”.", title); + return (true, null); + } + var detail = await res.Content.ReadAsStringAsync(); + _log.LogWarning("Teams notification for “{Title}” failed: webhook returned {Status}. {Detail}", + title, (int)res.StatusCode, Trim(detail)); + return (false, $"Teams webhook returned {(int)res.StatusCode}. {Trim(detail)}"); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Teams notification for “{Title}” failed: {Message}", title, ex.Message); + return (false, ex.Message); + } + } + + static string Trim(string s) => string.IsNullOrEmpty(s) ? "" : (s.Length > 400 ? s[..400] + "…" : s); + + // Scheme+host of the configured webhook, for a "points at …" hint in the UI + // without ever leaking the secret path/token. Empty when unset/unparseable. + static string MaskedHost(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var u) ? $"{u.Scheme}://{u.Host}" : ""; + + public static void MapTeamsConnectorEndpoints(this RouteGroupBuilder api) + { + // Connector status — whether a webhook is set, whether delivery is on, and + // a masked host. Viewing integrations needs View on "Integrations & + // connectors"; the raw webhook URL is never returned. + api.MapGet("/integrations/teams/status", async (AtlasDbContext db, IConfiguration cfg, HttpContext http) => + { + if (await Permissions.Deny(http, db, cfg, "cap-integrations", "V") is { } denied) return denied; + var canManage = await Permissions.Allows(http, db, cfg, "cap-integrations", "E"); + var url = await GetSettingAsync(db, WebhookKey); + var enabled = (await GetSettingAsync(db, EnabledKey)) == "true"; + return Results.Ok(new + { + configured = !string.IsNullOrWhiteSpace(url), + enabled, + host = MaskedHost(url), + canManage, + }); + }); + + // Save the webhook URL and/or the enabled toggle. Managing integrations + // needs Edit. A blank WebhookUrl clears it (disconnect); a non-blank one + // must be an absolute https URL (webhooks are always TLS). + api.MapPost("/integrations/teams/config", async (TeamsConfigReq req, AtlasDbContext db, IConfiguration cfg, HttpContext http) => + { + if (await Permissions.Deny(http, db, cfg, "cap-integrations", "E") is { } denied) return denied; + + if (req.WebhookUrl is not null) + { + var url = req.WebhookUrl.Trim(); + if (url.Length > 0 && !(Uri.TryCreate(url, UriKind.Absolute, out var u) && u.Scheme == Uri.UriSchemeHttps)) + return Results.BadRequest(new { error = "Enter a valid https webhook URL (create one in Teams → Workflows → “Post to a channel when a webhook request is received”)." }); + await SetAsync(db, WebhookKey, url); + // Clearing the URL also mutes delivery so a later re-enable is explicit. + if (url.Length == 0) await SetAsync(db, EnabledKey, "false"); + } + if (req.Enabled is { } en) await SetAsync(db, EnabledKey, en ? "true" : "false"); + + // Audit without the secret — record only that it was set/cleared. + var what = req.WebhookUrl is null ? "toggle" : (req.WebhookUrl.Trim().Length > 0 ? "set webhook" : "cleared webhook"); + db.AuditEvents.Add(Permissions.Audit(http, cfg, "Integrations", "Configured Teams channel", what)); + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + // Live test — post a card to the configured channel and report back. Needs + // Edit (same bar as configuring it). + api.MapPost("/integrations/teams/test", async (AtlasDbContext db, IConfiguration cfg, HttpContext http) => + { + if (await Permissions.Deny(http, db, cfg, "cap-integrations", "E") is { } denied) return denied; + var url = await GetSettingAsync(db, WebhookKey); + if (string.IsNullOrWhiteSpace(url)) + return Results.Ok(new { ok = false, error = "No webhook configured. Add the channel webhook URL first." }); + var (ok, error) = await PostAsync(url, + "Test notification", + $"Atlas is connected to this channel. Sent by {Permissions.ActorName(http, cfg)}."); + db.AuditEvents.Add(Permissions.Audit(http, cfg, "Integrations", "Tested Teams channel", ok ? "ok" : "failed")); + await db.SaveChangesAsync(); + return Results.Ok(new { ok, error }); + }); + } + + static async Task SetAsync(AtlasDbContext db, string key, string value) + { + var s = await db.Settings.FindAsync(key); + if (s is null) db.Settings.Add(new Setting { Key = key, Value = value }); + else s.Value = value; + } +} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 904676a..02aab23 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -72,6 +72,7 @@ const P: Record = { database: "M12 8c4.42 0 8-1.34 8-3s-3.58-3-8-3-8 1.34-8 3 3.58 3 8 3zM4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6", server: "M4 4h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM4 14h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1zM7 7h.01M7 17h.01", gitBranch: "M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM15 6a9 9 0 0 1-9 9", + mousePointer: "M3 3l7.07 17.97 2.51-7.39 7.39-2.51L3 3z", globe: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z", award: "M12 15a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM8.21 13.89 7 23l5-3 5 3-1.21-9.12", send: "M22 2 11 13M22 2l-7 20-4-9-9-4z", diff --git a/src/screens/Integrations.tsx b/src/screens/Integrations.tsx index 109ec8c..17a9b9e 100644 --- a/src/screens/Integrations.tsx +++ b/src/screens/Integrations.tsx @@ -94,6 +94,21 @@ export default function Integrations() { onError: (e) => toast((e as Error).message, "error"), }); + // Microsoft Teams is a live connector: a channel webhook turns every Atlas + // notification into an Adaptive Card in a Teams channel (ADR-0060). The raw + // webhook URL is never returned — status carries only a masked host + flags. + const { data: teams } = useQuery({ + queryKey: ["teams-status"], retry: false, staleTime: 30_000, + queryFn: async (): Promise<{ configured: boolean; enabled: boolean; host: string; canManage: boolean }> => + (await api<{ configured: boolean; enabled: boolean; host: string; canManage: boolean }>("/integrations/teams/status")) ?? { configured: false, enabled: false, host: "", canManage: false }, + }); + const testTeams = useMutation({ + mutationFn: () => api<{ ok: boolean; error?: string }>("/integrations/teams/test", { method: "POST" }), + onSuccess: (r) => toast(r?.ok ? "Test posted to the Teams channel." : (r?.error ?? "Teams test failed."), r?.ok ? "info" : "error"), + onError: (e) => toast((e as Error).message, "error"), + }); + const [configuringTeams, setConfiguringTeams] = useState(false); + return (
{/* Identity & platform */} @@ -202,6 +217,39 @@ export default function Integrations() {
); } + // Microsoft Teams is wired to the real backend (channel webhook config + // + live test). Configured ⇒ notifications post as Adaptive Cards. + if (a.name === "Microsoft Teams") { + const configured = !!teams?.configured; + const live = configured && !!teams?.enabled; + const detail = configured && teams?.host ? `Channel webhook · ${teams.host}` : a.detail; + return ( +
+
{a.initials}
+
+
{a.name}
+
{detail}
+
+
+ {live ? "Connected" : configured ? "Muted" : "Not connected"} + + {configured && ( + + )} +
+
+ ); + } const on = !!connected[a.name]; const toggle = () => setConnected((s) => ({ ...s, [a.name]: !s[a.name] })); return ( @@ -225,6 +273,79 @@ export default function Integrations() { {jira?.configured && } {ado?.configured && } + + {configuringTeams && ( + setConfiguringTeams(false)} + onDone={() => { setConfiguringTeams(false); qc.invalidateQueries({ queryKey: ["teams-status"] }); }} + /> + )} + + ); +} + +// ---- Microsoft Teams channel configuration (ADR-0060) ---------------------- +function ConfigureTeamsModal({ enabled, configured, onClose, onDone }: { enabled: boolean; configured: boolean; onClose: () => void; onDone: () => void }) { + // The webhook URL is write-only — the server never returns it — so the field + // starts blank: leave it empty to keep the stored URL, or paste a new one. + const [webhook, setWebhook] = useState(""); + // Default delivery ON for a first-time setup so paste-and-save just works; + // for an already-configured channel, reflect its current state. + const [on, setOn] = useState(configured ? enabled : true); + + const save = useMutation({ + mutationFn: () => api("/integrations/teams/config", { + method: "POST", + body: JSON.stringify({ + // Only send the URL when the user typed one (blank ⇒ leave unchanged). + ...(webhook.trim() ? { webhookUrl: webhook.trim() } : {}), + enabled: on, + }), + }), + onSuccess: () => { toast("Teams channel saved.", "info"); onDone(); }, + onError: (e) => toast((e as Error).message, "error"), + }); + const clear = useMutation({ + mutationFn: () => api("/integrations/teams/config", { method: "POST", body: JSON.stringify({ webhookUrl: "" }) }), + onSuccess: () => { toast("Teams channel disconnected.", "info"); onDone(); }, + onError: (e) => toast((e as Error).message, "error"), + }); + + return ( +
+
e.stopPropagation()} style={{ background: color.surface, borderRadius: 14, padding: 22, width: 480, maxWidth: "92vw" }}> +
Microsoft Teams channel
+
Post every Atlas notification to a Teams channel as an Adaptive Card.
+ +
Incoming webhook URL
+ setWebhook(e.target.value)} + type="url" + placeholder={configured ? "•••••••• (leave blank to keep current)" : "https://…/workflows/…"} + aria-label="Teams incoming webhook URL" + style={{ width: "100%", padding: "8px 10px", borderRadius: 8, border: `1px solid ${color.border}`, fontSize: 13, fontFamily: "inherit" }} + /> +
+ In Teams, add Workflows → “Post to a channel when a webhook request is received” to the target channel and paste the generated URL here. See docs/teams-setup.md. +
+ + + +
+ {configured && ( + + )} +
+ + +
+
); } diff --git a/src/screens/Pip.tsx b/src/screens/Pip.tsx index 063217c..2e87155 100644 --- a/src/screens/Pip.tsx +++ b/src/screens/Pip.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { lazy, Suspense, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { color, font, chart } from "@/theme"; import { Icon } from "@/components/Icon"; @@ -12,6 +12,9 @@ import { parseTs, toDisplay, confColor, isOverAllocated, iterationTotals, objectiveRollup, timelineSpan, barPos, monthTicks, } from "./pip/data"; +// Lazy so the SignalR client only loads when the live board is opened (ADR-0027). +const ProgramBoard = lazy(() => import("./pip/ProgramBoard")); + // ============================================================================ // Program Increment Planning (PIP) — quarterly PI planning across the whole // portfolio. An increment holds PI objectives (business value + team @@ -46,7 +49,7 @@ export default function Pip() { const { can } = usePermissions(); const canEdit = can("cap-schedule", "E"); const [selected, setSelected] = useState(null); - const [tab, setTab] = useState<"objectives" | "calendar" | "capacity" | "dependencies">("objectives"); + const [tab, setTab] = useState<"board" | "objectives" | "calendar" | "capacity" | "dependencies">("objectives"); const [showNew, setShowNew] = useState(false); const [quickCreate, setQuickCreate] = useState(null); const canCreatePortfolio = can("cap-projects", "F"); @@ -112,6 +115,7 @@ export default function Pip() {
{([ + ["board", "Program Board", 0], ["objectives", "PI Objectives", inc.objectiveList.length], ["calendar", "Calendar", inc.iterationList.length], ["capacity", "Capacity & Load", inc.iterationList.length], @@ -123,6 +127,11 @@ export default function Pip() { ))}
+ {tab === "board" && ( + }> + + + )} {tab === "objectives" && } {tab === "calendar" && } {tab === "capacity" && } diff --git a/src/screens/pip/ProgramBoard.tsx b/src/screens/pip/ProgramBoard.tsx new file mode 100644 index 0000000..d51f463 --- /dev/null +++ b/src/screens/pip/ProgramBoard.tsx @@ -0,0 +1,314 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { color, font } from "@/theme"; +import { api } from "@/api"; +import { Icon } from "@/components/Icon"; +import { Button, Input, Select, Field, Modal, EmptyBlock } from "@/components/ui"; +import { toast, toastError } from "@/components/Toast"; +import { useRole } from "@/components/RoleContext"; +import type { IncrementDetail, Objective } from "./data"; +import { DEP_COL, DEP_STATUSES, OBJ_PILL } from "./data"; +import { + type Lane, columns, deriveLanes, cardsFor, dependencyLaneLinks, arrowPath, +} from "./board"; +import { useBoardRealtime } from "./useBoardRealtime"; + +interface BoardData { canEdit: boolean; placements: Record } + +// ============================================================================ +// PI Program Board (ADR-0061) — a live swimlane board. Rows are the linked +// deliverables, columns the increment's iterations; PI objectives are cards +// you drag between iterations, and dependencies are arrows drawn between lanes. +// Presence, peer cursors and change-sync come from the SignalR hub; the data +// itself is read/written through the existing REST API (cap-schedule). +// ============================================================================ +export default function ProgramBoard({ inc }: { inc: IncrementDetail }) { + const qc = useQueryClient(); + const { identity } = useRole(); + const canEdit = inc.canEdit; + + const boardQ = useQuery({ + queryKey: ["increment-board", inc.id], retry: false, staleTime: 0, + queryFn: async () => (await api(`/increments/${inc.id}/board`)) ?? { canEdit: false, placements: {} }, + }); + const placements = useMemo(() => boardQ.data?.placements ?? {}, [boardQ.data]); + + // A peer changed the board → refetch the authoritative state. + const onChanged = useCallback(() => { + qc.invalidateQueries({ queryKey: ["increment", inc.id] }); + qc.invalidateQueries({ queryKey: ["increment-board", inc.id] }); + }, [qc, inc.id]); + const { peers, cursors, connected, sendCursor, notifyChanged } = useBoardRealtime(inc.id, identity.name, onChanged); + + const move = useMutation({ + mutationFn: (v: { objectiveId: number; iterationId: number | null }) => + api(`/increments/${inc.id}/board/placement`, { method: "PUT", body: JSON.stringify(v) }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["increment-board", inc.id] }); notifyChanged(); }, + onError: toastError, + }); + + const lanes = useMemo(() => deriveLanes(inc.objectiveList, inc.dependencyList), [inc.objectiveList, inc.dependencyList]); + const cols = useMemo(() => columns(inc.iterationList), [inc.iterationList]); + const iterationIds = useMemo(() => new Set(inc.iterationList.map((i) => i.id)), [inc.iterationList]); + const links = useMemo(() => dependencyLaneLinks(inc.dependencyList), [inc.dependencyList]); + + // --- Geometry: measure lane-row centres + surface size for arrows/cursors --- + const surfaceRef = useRef(null); + const laneEls = useRef>({}); + const [laneY, setLaneY] = useState>({}); + const [size, setSize] = useState({ w: 0, h: 0 }); + const [tick, setTick] = useState(0); + useLayoutEffect(() => { + const surf = surfaceRef.current; + if (!surf) return; + const base = surf.getBoundingClientRect(); + const ys: Record = {}; + for (const [key, el] of Object.entries(laneEls.current)) { + if (!el) continue; + const r = el.getBoundingClientRect(); + ys[key] = r.top - base.top + r.height / 2; + } + setLaneY(ys); + setSize({ w: surf.offsetWidth, h: surf.offsetHeight }); + }, [lanes, cols, placements, inc.dependencyList, tick]); + useLayoutEffect(() => { + const onResize = () => setTick((t) => t + 1); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + const onMouseMove = useCallback((e: React.MouseEvent) => { + const surf = surfaceRef.current; + if (!surf) return; + const r = surf.getBoundingClientRect(); + if (r.width && r.height) sendCursor((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height); + }, [sendCursor]); + + // --- Dependency linking (click a lane's link handle, then a target lane) ---- + const [linkFrom, setLinkFrom] = useState(null); + const [linkTo, setLinkTo] = useState(null); + const onLaneLink = (lane: Lane) => { + if (!canEdit) return; + if (!linkFrom) { setLinkFrom(lane); return; } + if (lane.key === linkFrom.key) { setLinkFrom(null); return; } // cancel + setLinkTo(lane); + }; + + const RAIL = 220, ARROW_X = RAIL - 16; + const gridCols = `${RAIL}px repeat(${cols.length}, minmax(190px, 1fr))`; + + if (lanes.length === 0) { + return ; + } + + return ( +
+ {/* Toolbar: live status + presence + legend */} +
+
+ + {connected ? "Live" : "Offline"} +
+ +
+
+ {DEP_STATUSES.map((s) => ( + + {s} + + ))} +
+
+ + {linkFrom && ( +
+ Linking dependency from {linkFrom.name} — click another lane's link handle to set the target. + +
+ )} + +
+
+ {/* Header row */} +
+ {cols.map((c) => ( +
+ {c.name} +
+ ))} + + {/* Lane rows */} + {lanes.map((lane) => ( + onLaneLink(lane)} + onDropCard={(objId, colId) => move.mutate({ objectiveId: objId, iterationId: colId })} + railWidth={RAIL} + anchorRef={(el) => { laneEls.current[lane.key] = el; }} + /> + ))} + + {/* Dependency arrows (overlay, non-interactive) */} + + + {DEP_STATUSES.map((s) => ( + + + + ))} + + {links.map((l) => { + const y1 = laneY[l.from], y2 = laneY[l.to]; + if (y1 == null || y2 == null) return null; + return ( + + {l.title} ({l.status}) + + ); + })} + + + {/* Peer cursors (overlay, non-interactive) */} + {cursors.map((cur) => { + const peer = peers.find((p) => p.id === cur.id); + if (!peer) return null; + return ( +
+ + {peer.name} +
+ ); + })} +
+
+ + {linkFrom && linkTo && ( + { setLinkFrom(null); setLinkTo(null); }} + onDone={() => { setLinkFrom(null); setLinkTo(null); qc.invalidateQueries({ queryKey: ["increment", inc.id] }); notifyChanged(); }} + /> + )} +
+ ); +} + +const headCell: React.CSSProperties = { + padding: "10px 12px", fontSize: 12, fontWeight: 700, color: color.subtle, + borderBottom: `1px solid ${color.border}`, borderLeft: `1px solid ${color.border}`, + position: "sticky", top: 0, zIndex: 2, whiteSpace: "nowrap", +}; + +function PresenceRow({ peers }: { peers: { id: string; name: string; initials: string; color: string }[] }) { + if (peers.length === 0) return null; + return ( +
+ {peers.slice(0, 6).map((p, i) => ( + + {p.initials} + + ))} + {peers.length > 6 && +{peers.length - 6}} +
+ ); +} + +function LaneRow({ + lane, cols, objectives, placements, iterationIds, canEdit, linking, isLinkSource, onLink, onDropCard, railWidth, anchorRef, +}: { + lane: Lane; cols: { id: number | null; name: string }[]; objectives: Objective[]; + placements: Record; iterationIds: ReadonlySet; + canEdit: boolean; linking: boolean; isLinkSource: boolean; + onLink: () => void; onDropCard: (objId: number, colId: number | null) => void; + railWidth: number; anchorRef: (el: HTMLDivElement | null) => void; +}) { + const [over, setOver] = useState(null); + return ( + <> +
+
+
{lane.name}
+ {lane.type &&
{lane.type}
} +
+ {canEdit && lane.key !== "unassigned" && ( + + )} +
+ {cols.map((c) => { + const cards = cardsFor(objectives, lane.key, c.id, placements, iterationIds); + const key = c.id ?? "nil"; + return ( +
{ e.preventDefault(); setOver(key); } : undefined} + onDragLeave={() => setOver((o) => (o === key ? null : o))} + onDrop={canEdit ? (e) => { e.preventDefault(); setOver(null); const id = Number(e.dataTransfer.getData("text/plain")); if (id) onDropCard(id, c.id); } : undefined} + style={{ padding: 8, borderBottom: `1px solid ${color.border}`, borderLeft: `1px solid ${color.border}`, background: over === key ? color.primaryTint : c.id == null ? color.bg : color.surface, minHeight: 64, display: "flex", flexDirection: "column", gap: 7 }}> + {cards.map((o) => )} +
+ ); + })} + + ); +} + +function ObjectiveCard({ obj, canEdit }: { obj: Objective; canEdit: boolean }) { + const pill = OBJ_PILL[obj.status] ?? OBJ_PILL.Planned; + return ( +
e.dataTransfer.setData("text/plain", String(obj.id))} + style={{ border: `1px solid ${color.border}`, borderLeft: `3px solid ${pill.dot}`, borderRadius: 8, padding: "8px 10px", background: color.surface, cursor: canEdit ? "grab" : "default", boxShadow: "0 1px 2px rgba(20,26,60,0.05)" }}> +
{obj.title}
+
+ {obj.status} + BV {obj.businessValue} + {!obj.committed && Stretch} +
+
+ ); +} + +function LinkDependencyModal({ incId, from, to, onClose, onDone }: { + incId: number; from: Lane; to: Lane; onClose: () => void; onDone: () => void; +}) { + const [title, setTitle] = useState(""); + const [status, setStatus] = useState("Identified"); + const create = useMutation({ + mutationFn: () => api(`/increments/${incId}/dependencies`, { + method: "POST", + body: JSON.stringify({ title: title.trim(), fromType: from.type, fromId: from.id, toType: to.type, toId: to.id, status }), + }), + onSuccess: () => { toast("Dependency linked.", "info"); onDone(); }, + onError: toastError, + }); + return ( + +
Link dependency
+
+ {from.name} {to.name} +
+ {(id) => setTitle(e.target.value)} placeholder="e.g. Auth API ready before checkout" />} + {(id) => ( + + )} +
+ + +
+
+ ); +} diff --git a/src/screens/pip/board.test.ts b/src/screens/pip/board.test.ts new file mode 100644 index 0000000..07ddc8d --- /dev/null +++ b/src/screens/pip/board.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { + laneKey, deriveLanes, columns, placedColumn, cardsFor, dependencyLaneLinks, arrowPath, UNASSIGNED, +} from "./board"; +import type { Objective, Dependency, Iteration } from "./data"; + +const obj = (id: number, entityType = "", entityId = "", entityName = ""): Objective => ({ + id, title: `O${id}`, description: "", entityType, entityId, entityName, + businessValue: 5, actualValue: 0, committed: true, confidence: 3, status: "Planned", +}); +const dep = (id: number, fromType: string, fromId: string, toType: string, toId: string, status = "Identified"): Dependency => ({ + id, title: `D${id}`, fromType, fromId, fromName: fromId, toType, toId, toName: toId, owner: "", dueDate: "", status, +}); +const iter = (id: number, name: string): Iteration => ({ id, name, startDate: "", endDate: "", capacity: 0, load: 0 }); + +describe("laneKey", () => { + it("keys by type:id and folds empties into the shared unassigned lane", () => { + expect(laneKey("project", "PRJ-1")).toBe("project:PRJ-1"); + expect(laneKey("", "")).toBe(UNASSIGNED); + expect(laneKey("project", "")).toBe(UNASSIGNED); + }); +}); + +describe("deriveLanes", () => { + it("collects distinct lanes from objectives and dependency ends, unassigned last", () => { + const lanes = deriveLanes( + [obj(1, "project", "PRJ-1", "Alpha"), obj(2, "", "", ""), obj(3, "project", "PRJ-1", "Alpha")], + [dep(9, "project", "PRJ-1", "program", "PRG-2")], + ); + expect(lanes.map((l) => l.key)).toEqual(["project:PRJ-1", "program:PRG-2", UNASSIGNED]); + expect(lanes.find((l) => l.key === "project:PRJ-1")?.name).toBe("Alpha"); + expect(lanes.at(-1)?.name).toBe("Unassigned"); + }); +}); + +describe("columns", () => { + it("prepends an Unscheduled tray before the iterations", () => { + expect(columns([iter(1, "It 1"), iter(2, "It 2")])).toEqual([ + { id: null, name: "Unscheduled" }, { id: 1, name: "It 1" }, { id: 2, name: "It 2" }, + ]); + }); +}); + +describe("placedColumn", () => { + const iters = new Set([1, 2]); + it("returns the placed iteration when it still exists", () => { + expect(placedColumn(5, { "5": 2 }, iters)).toBe(2); + }); + it("falls back to Unscheduled for missing or stale placements", () => { + expect(placedColumn(5, {}, iters)).toBeNull(); + expect(placedColumn(5, { "5": 99 }, iters)).toBeNull(); // iteration deleted + }); +}); + +describe("cardsFor", () => { + const iters = new Set([1, 2]); + const objs = [obj(1, "project", "PRJ-1"), obj(2, "project", "PRJ-1"), obj(3, "program", "PRG-2")]; + it("selects objectives by lane and column", () => { + const placements = { "1": 2 }; // obj1 → iteration 2; obj2 unscheduled + expect(cardsFor(objs, "project:PRJ-1", 2, placements, iters).map((o) => o.id)).toEqual([1]); + expect(cardsFor(objs, "project:PRJ-1", null, placements, iters).map((o) => o.id)).toEqual([2]); + expect(cardsFor(objs, "program:PRG-2", null, placements, iters).map((o) => o.id)).toEqual([3]); + }); +}); + +describe("dependencyLaneLinks", () => { + it("maps deps to lane pairs and drops self-links", () => { + const links = dependencyLaneLinks([ + dep(1, "project", "PRJ-1", "program", "PRG-2", "Blocked"), + dep(2, "project", "PRJ-1", "project", "PRJ-1"), // self-link → dropped + ]); + expect(links).toEqual([{ id: 1, from: "project:PRJ-1", to: "program:PRG-2", status: "Blocked", title: "D1" }]); + }); +}); + +describe("arrowPath", () => { + it("produces a cubic bezier bowed by the given amount", () => { + expect(arrowPath(0, 10, 0, 50, 40)).toBe("M 0 10 C 40 10, 40 50, 0 50"); + }); +}); diff --git a/src/screens/pip/board.ts b/src/screens/pip/board.ts new file mode 100644 index 0000000..cf615a0 --- /dev/null +++ b/src/screens/pip/board.ts @@ -0,0 +1,86 @@ +// ============================================================================ +// Program Board — pure derivations (no React/DOM). The board lays PI +// objectives on a swimlane grid: rows are the linked deliverables, columns are +// the increment's iterations (plus an "Unscheduled" column), and cross-team +// dependencies are drawn as arrows between lanes. Kept separate from the view +// so the layout maths stays unit-testable. See ADR-0061. +// ============================================================================ +import type { Objective, Dependency, Iteration } from "./data"; + +export interface Lane { key: string; type: string; id: string; name: string } +export interface BoardColumn { id: number | null; name: string } // id null ⇒ Unscheduled + +export const UNASSIGNED = "unassigned"; + +// A stable lane key for an entity reference. Empty type/id ⇒ the shared +// "unassigned" lane (objectives/dependency ends with no linked deliverable). +export function laneKey(type: string, id: string): string { + return type && id ? `${type}:${id}` : UNASSIGNED; +} + +// The lanes present on the board: every distinct deliverable referenced by an +// objective or a dependency end. "Unassigned" (if any) always sorts last. +export function deriveLanes(objectives: Objective[], dependencies: Dependency[]): Lane[] { + const map = new Map(); + const add = (type: string, id: string, name: string) => { + const key = laneKey(type, id); + if (!map.has(key)) { + map.set(key, key === UNASSIGNED + ? { key, type: "", id: "", name: "Unassigned" } + : { key, type, id, name: name || id }); + } + }; + for (const o of objectives) add(o.entityType, o.entityId, o.entityName); + for (const d of dependencies) { + add(d.fromType, d.fromId, d.fromName); + add(d.toType, d.toId, d.toName); + } + return [...map.values()].sort((a, b) => + a.key === UNASSIGNED ? 1 : b.key === UNASSIGNED ? -1 : 0); +} + +// Board columns: an "Unscheduled" tray first, then the increment's iterations +// in order. +export function columns(iterations: Iteration[]): BoardColumn[] { + return [{ id: null, name: "Unscheduled" }, ...iterations.map((i) => ({ id: i.id, name: i.name }))]; +} + +// Which column an objective sits in: its stored placement if that iteration +// still exists, otherwise the Unscheduled tray (null). +export function placedColumn( + objId: number, + placements: Record, + iterationIds: ReadonlySet, +): number | null { + const it = placements[String(objId)]; + return it != null && iterationIds.has(it) ? it : null; +} + +// Objectives that belong in one cell (lane × column). +export function cardsFor( + objectives: Objective[], + lane: string, + colId: number | null, + placements: Record, + iterationIds: ReadonlySet, +): Objective[] { + return objectives.filter( + (o) => laneKey(o.entityType, o.entityId) === lane && placedColumn(o.id, placements, iterationIds) === colId, + ); +} + +// Dependencies as lane→lane links (self-links dropped — nothing to draw). +export interface LaneLink { id: number; from: string; to: string; status: string; title: string } +export function dependencyLaneLinks(dependencies: Dependency[]): LaneLink[] { + return dependencies + .map((d) => ({ id: d.id, from: laneKey(d.fromType, d.fromId), to: laneKey(d.toType, d.toId), status: d.status, title: d.title })) + .filter((l) => l.from !== l.to); +} + +// A cubic-bezier path between two points, bowed horizontally by `bow` px so +// parallel arrows on the same rail stay legible. Used for the dependency arrows. +export function arrowPath(x1: number, y1: number, x2: number, y2: number, bow = 42): string { + const cx1 = x1 + bow; + const cx2 = x2 + bow; + return `M ${x1} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${x2} ${y2}`; +} diff --git a/src/screens/pip/useBoardRealtime.ts b/src/screens/pip/useBoardRealtime.ts new file mode 100644 index 0000000..7dcddfe --- /dev/null +++ b/src/screens/pip/useBoardRealtime.ts @@ -0,0 +1,81 @@ +// ============================================================================ +// Real-time board transport (ADR-0061). Wraps the SignalR client into a small +// hook: live presence, peer cursors, and a "board changed" ping that tells the +// view to refetch from the authoritative REST API. No domain data travels over +// the socket. Everything degrades gracefully — if the hub can't connect, the +// board still works, just without the live layer. +// ============================================================================ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { HubConnectionBuilder, type HubConnection, LogLevel } from "@microsoft/signalr"; +import { getToken } from "@/auth"; + +export interface Peer { id: string; name: string; initials: string; color: string } +export interface PeerCursor { id: string; x: number; y: number } + +export function useBoardRealtime(incrementId: number | null, myName: string, onChanged: () => void) { + const [peers, setPeers] = useState([]); + const [cursors, setCursors] = useState>({}); + const [connected, setConnected] = useState(false); + const connRef = useRef(null); + const lastCursor = useRef(0); + // Keep the latest onChanged without re-subscribing the socket each render. + const onChangedRef = useRef(onChanged); + useLayoutEffect(() => { onChangedRef.current = onChanged; }, [onChanged]); + + useEffect(() => { + if (incrementId == null) return; + let disposed = false; + const conn = new HubConnectionBuilder() + // Relative URL ⇒ same-origin (dev: Vite proxy; prod: nginx /hubs). The + // bearer token rides the query string because the WS handshake can't carry + // an Authorization header (the server accepts it only for /hubs). + .withUrl("/hubs/board", { accessTokenFactory: async () => (await getToken()) ?? "" }) + .withAutomaticReconnect() + .configureLogging(LogLevel.Warning) + .build(); + connRef.current = conn; + + const join = () => conn.invoke("JoinBoard", incrementId, myName).catch(() => {}); + conn.on("Presence", (list: Peer[]) => setPeers(list)); + conn.on("PeerJoined", (p: Peer) => setPeers((cur) => (cur.some((x) => x.id === p.id) ? cur : [...cur, p]))); + conn.on("PeerLeft", (id: string) => { + setPeers((cur) => cur.filter((x) => x.id !== id)); + setCursors((cur) => { const n = { ...cur }; delete n[id]; return n; }); + }); + conn.on("Cursor", (id: string, x: number, y: number) => setCursors((cur) => ({ ...cur, [id]: { id, x, y } }))); + conn.on("BoardChanged", () => onChangedRef.current()); + + conn.onreconnecting(() => setConnected(false)); + conn.onreconnected(() => { setConnected(true); join(); }); + conn.onclose(() => setConnected(false)); + + conn.start() + .then(() => { if (!disposed) { setConnected(true); return join(); } }) + .catch(() => { /* no realtime — the board remains fully usable */ }); + + return () => { + disposed = true; + conn.stop().catch(() => {}); + connRef.current = null; + setPeers([]); setCursors({}); setConnected(false); + }; + }, [incrementId, myName]); + + // Broadcast my cursor, throttled (~16/s) so a fast mouse can't flood the hub. + const sendCursor = useCallback((x: number, y: number) => { + const c = connRef.current; + if (!c || incrementId == null) return; + const now = Date.now(); + if (now - lastCursor.current < 60) return; + lastCursor.current = now; + c.invoke("Cursor", incrementId, x, y).catch(() => {}); + }, [incrementId]); + + // Tell peers I changed the board (after a successful REST write) so they refetch. + const notifyChanged = useCallback(() => { + const c = connRef.current; + if (c && incrementId != null) c.invoke("NotifyChanged", incrementId).catch(() => {}); + }, [incrementId]); + + return { peers, cursors: Object.values(cursors), connected, sendCursor, notifyChanged }; +} diff --git a/vite.config.ts b/vite.config.ts index 5168eb6..9aaa03e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -36,6 +36,12 @@ export default defineConfig({ target: process.env.VITE_API_PROXY || "http://localhost:8081", changeOrigin: true, }, + // PI Program Board SignalR hub — needs WebSocket upgrade (ws: true). + "/hubs": { + target: process.env.VITE_API_PROXY || "http://localhost:8081", + changeOrigin: true, + ws: true, + }, }, }, });