diff --git a/.bun-version b/.bun-version new file mode 100644 index 0000000..428b770 --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.4.3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a106a97..57e2cf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version-file: .bun-version - run: bun install --frozen-lockfile - run: bun run typecheck - run: bun run test:coverage @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version-file: .bun-version - run: bun install --frozen-lockfile - run: bun test env: @@ -63,7 +63,7 @@ jobs: - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version-file: .bun-version - run: bun install --frozen-lockfile - run: SITE_BASE=/ bun run site:build - run: test -f site-dist/index.html && test -f site-dist/docs/index.html && test -f site-dist/logo-256.png diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d8a1ba6..7570a52 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,7 +30,7 @@ jobs: - if: steps.key.outputs.present == 'true' uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version-file: .bun-version - if: steps.key.outputs.present == 'true' run: bun install --frozen-lockfile - if: steps.key.outputs.present == 'true' diff --git a/bun.lock b/bun.lock index 1d4d92c..d9bea0f 100644 --- a/bun.lock +++ b/bun.lock @@ -510,7 +510,7 @@ "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], - "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "body-parser": ["body-parser@2.4.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], @@ -842,7 +842,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + "qs": ["qs@6.17.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA=="], "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], diff --git a/docs/guide/api.md b/docs/guide/api.md index f1c18d6..4c01c8a 100644 --- a/docs/guide/api.md +++ b/docs/guide/api.md @@ -159,6 +159,16 @@ Requests to edit answers after the deadline are decided by anyone holding `stand | `GET` | `/edit-requests` | Pending requests the caller can decide, newest first: `standupId`, `standupName`, `date`, `userName`, `displayName`, `reason`, `askedAt`, `answeredAt` and `until` (midnight in the standup's timezone, as `23:59 Europe/London`) | | `POST` | `/edit-requests/:id/approve` · `/edit-requests/:id/decline` | `{ note? }` on decline goes to the person; `404` for an unknown request, `403` without `standups.configure` on its standup, `409 refused` once decided or lapsed, or for the caller's own request | +Polls with questions are sent and closed by anyone holding `standups.configure` on the standup: + +| Method | Path | Notes | +| --- | --- | --- | +| `GET` | `/standups/:id/question-polls` | Newest first: `title`, `from`, `closesAt` and `closesLabel` (the close time in the standup's timezone), `closedAt`, `open`, `anonymous`, `results` (`everyone` or `managers`), `asked`, `answered` | +| `POST` | `/standups/:id/question-polls` | `{ title, intro?, questions: [{ text, type, required?, options? }], closesAt: "2026-10-24T17:00", remindMinutes?: 15 \| 60 \| 1440 \| null, anonymous?, results? }`. `type` is `short`, `long`, `dropdown`, `single` or `multiple`; up to 10 questions and 20 options. `closesAt` is local to the standup, from 15 minutes to 14 days ahead. Answers `201` with the poll, or `400 invalid` naming the `field` | +| `GET` | `/question-polls/:id` | The poll and its `questions`. `outcome` (who hasn't answered, counts per option with names, text answers) is set for the standup's managers, and for the people asked once a poll with `results: everyone` closes; otherwise `null`. Names are `null` on an anonymous poll | +| `POST` | `/question-polls/:id/close` | Close it now; `409 closed` if it already has | +| `GET` | `/question-polls/:id/export.csv` | One row per answer: `person,question,answer,answered_at`, without `person` on an anonymous poll. Managers only | + Moods are withheld per person when the standup keeps them anonymous; only the team average is returned. A run includes `moodComments` only for callers holding `moods.comments` on the standup: each with `person`, `mood` and `comment`, or with `person: null`, sorted by mood then text, when moods are anonymous. ### Blockers diff --git a/docs/guide/commands.md b/docs/guide/commands.md index 62a1e00..488b5a5 100644 --- a/docs/guide/commands.md +++ b/docs/guide/commands.md @@ -87,7 +87,7 @@ Quick team decisions without a meeting — a card in the space, one tap to vote: | Command | Effect | | --- | --- | | `poll Question? \| Option A \| Option B` | Post a poll card (2–6 options). Votes update the card live; voting again changes your vote | -| `polls` | List open polls with vote counts | +| `polls` | List open polls with vote counts, and open polls with questions with how many have answered | | `poll results` | Current tallies with voter names | | `poll close` | Close it and post final results (creator or admin) | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3580ca2..d386134 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -97,6 +97,7 @@ Zapier/n8n, or your own service: | `wrap_up` | The run closes | `standup`, `date`, `summary` (the posted wrap-up's numbers, plus `teamMood` on anonymous standups) | | `schedule_change` | Someone's days on the standup, or their days off, change | person, summary, who, channel — see [Personal schedules](./schedules#integrations) | | `time_off_request` | A request needs approval | person, dates, working, reason, request ids | +| `poll_closed` | A poll with questions closes | `poll`: `id`, `title`, `anonymous`, `asked`, `answered` and each question's counts per option, with `names` only on a named poll | | `test` | **Send test** in the standup's settings | `standup` | Deliveries time out after 5 s and failures are only logged — a dead webhook diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 83e6f6d..1d6e7b3 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -30,7 +30,7 @@ Pick only the scopes the assistant needs: | Scope | Tools | | --- | --- | -| `read` | `get_me`, `list_standups`, `get_standup`, `get_my_standups`, `list_runs`, `get_run`, `list_blockers`, `get_team`, `get_insights`, `get_schedule`, `list_time_off_requests` | +| `read` | `get_me`, `list_standups`, `get_standup`, `get_my_standups`, `list_runs`, `get_run`, `list_blockers`, `get_team`, `get_insights`, `get_schedule`, `list_time_off_requests`, `list_question_polls`, `get_question_poll_results` (results only where the owner may see them) | | `blockers:write` | `acknowledge_blocker`, `update_blocker`, `resolve_blocker` | | `submit` | `submit_answers` (every answer required, and the mood when the standup asks for one; `moodComment` is optional), `skip_today` (takes a one-line `reason`, posted in the day's thread) | | `schedule` | `update_my_settings` (timezone, vacation), `set_working_days` (with `standupId`), `set_days_off`, `cancel_day_off`, `decide_time_off_request` | diff --git a/docs/guide/roles.md b/docs/guide/roles.md index 560d7a4..ade5d5e 100644 --- a/docs/guide/roles.md +++ b/docs/guide/roles.md @@ -38,7 +38,7 @@ Built-in roles can't be changed. Copy one to start a custom role. | `reports.view` | See run history and insights | ✓ | ✓ | ✓ | ✓ | | `people.view` | See the Team directory | ✓ | ✓ | ✓ | | | `export` | Download answers as CSV | ✓ | ✓ | ✓ | | -| `standups.configure` | Change the schedule, questions, mood, escalation and webhook; run now, nudge, close polls | ✓ | ✓ | | | +| `standups.configure` | Change the schedule, questions, mood, escalation and webhook; run now, nudge, close polls; send polls with questions and read their results | ✓ | ✓ | | | | `standups.archive` | Archive a standup and bring it back | ✓ | ✓ | | | | `people.manage` | Add and remove people, mark them required, make managers, grant roles on the standup | ✓ | ✓ | | | | `schedules.manage` | Change people's days and days off on the standup, decide time-off requests | ✓ | ✓ | | | diff --git a/docs/guide/user-guide.md b/docs/guide/user-guide.md index 10e635c..2ea84ce 100644 --- a/docs/guide/user-guide.md +++ b/docs/guide/user-guide.md @@ -87,3 +87,23 @@ bot, or straight away when your admins have set up the Anyone can start one in a space: `@AsyncUp poll Ship Friday? | Yes | No`. Tap an option to vote; tap another to change your vote. + +### Polls with questions + +A standup's managers can also send a poll with several questions from the +web app (the standup's **Polls** tab). Everyone on the standup, except people +on vacation, gets a DM card with **Respond**: + +- The form asks each question in turn: short or long text, a dropdown, one + choice (radio buttons) or several (checkboxes). Questions marked + *required* must be answered to submit. +- After you answer, **Change answers** reopens the form with your answers + filled in, until the poll closes. +- One reminder comes before the close if you haven't answered yet. +- The close time is shown in the standup's timezone. After it, nothing is + accepted, and the cards say *Closed*. +- An anonymous poll never shows who answered what, to anyone. A named poll + shows the managers who answered what. +- The space gets a card with how many have answered. When the poll closes, + the results post in its thread, or, if the managers keep them, a line + saying so. Text answers stay in the web app. diff --git a/docs/guide/web-app.md b/docs/guide/web-app.md index 609b5c9..98442ad 100644 --- a/docs/guide/web-app.md +++ b/docs/guide/web-app.md @@ -30,7 +30,8 @@ detects a working Chat connection and skips straight to the standup step. recent runs, open blockers, schedule), **History** (every run with full answers, CSV export, and mood comments for people allowed to read them), **Insights** (participation by person, mood and blocker - charts) and **Settings** (schedule with time pickers, questions, mood, the + charts), **Polls** (polls with questions: open and closed, **New poll** for + managers, and each poll's results, CSV export and **Close now**) and **Settings** (schedule with time pickers, questions, mood, the People grid and roster group, escalation, webhook with a signed test, digest, and a Danger zone to archive, unarchive or delete). - **Blockers** — every open, acknowledged or resolved blocker across the diff --git a/site/index.html b/site/index.html index 1d512f8..3ac8684 100644 --- a/site/index.html +++ b/site/index.html @@ -81,7 +81,7 @@

Everything a standup bot should do. Nothing it shouldn’t.

Away-aware

Skip today with a one-line reason the team can see, vacation mode and Google Calendar OOO sync. Away people are neither nagged nor counted.

Blockers that follow up

Tag teammates, acknowledge, post updates, resolve. Daily nudges until acknowledged; escalation when stale.

People and schedules

Prompts at 09:30 in each person’s own zone, personal weeks and days off, and rosters that follow a Google Group or drop people who leave the Chat space. Leave shows as a Chat status and a Calendar out-of-office.

-

Insights built in

Participation, mood and blocker trends. Weekly digest, CSV export, signed webhooks.

+

Insights built in

Participation, mood and blocker trends. Polls with a few questions and a close time. Weekly digest, CSV export, signed webhooks.

Enterprise SSO, no tax

Google sign-in, SAML 2.0 and SCIM provisioning — all in the MIT core.

MCP server

Claude Code and other assistants can read standups, answer or skip, work blockers and nudge the team, with scoped tokens. No model runs inside AsyncUp.

diff --git a/specs/008-question-polls.md b/specs/008-question-polls.md new file mode 100644 index 0000000..3b3ec78 --- /dev/null +++ b/specs/008-question-polls.md @@ -0,0 +1,178 @@ +# 008: Polls with questions and a deadline + +| | | +| --- | --- | +| Status | Done | +| Size | Cross-cutting (migration, Chat cards and dialogs, web app, API, MCP, webhook) | +| Figma | [Specs › 008 Polls with questions](https://www.figma.com/design/OQfcL61rbaGakS2FjBKasI?node-id=186-82) | +| PR | Link once opened | + +## Problem + +A manager who wants a quick read from the team, such as a release retro, a +lunch order or an on-call preference, has two poor options today. The +`poll` command asks one question with up to six buttons, in the space, with +no deadline. A Google Form or a separate poll tool loses the roster: nobody +knows who hasn't answered, and nobody reminds them. AsyncUp already knows +each standup's people, their timezones and who is away, so it can ask a +few questions of exactly those people and close on time. + +## User stories + +- As a standup manager, I want to send my team a one-off poll with several + questions of different kinds, so I get the answers I need in one go. +- As a standup manager, I want the poll to close at a set time, with a + reminder to anyone who hasn't answered, so I get answers in time. +- As someone on the standup, I want to answer in Chat and change my answer + until it closes. +- As a standup manager, I want the results in one place, named or + anonymous as I chose, and exportable. + +## Acceptance criteria + +Creating a poll (web app) + +1. Anyone with `standups.configure` on a standup can create a poll from + its **Polls** tab: a title (up to 100 characters), an optional intro + (up to 500), and 1 to 10 questions. +2. Each question has text (up to 200 characters), a type and whether it + is required. The types are: + - **Short text**: one line, up to 200 characters. + - **Long text**: several lines, up to 1,000 characters. + - **Dropdown**: one choice from a menu. + - **Single choice**: one choice, shown as radio buttons. + - **Multiple choice**: any number of choices, shown as checkboxes. + + Choice questions have 2 to 20 options of up to 80 characters each. +3. The poll has a close time, from 15 minutes to 14 days ahead, entered + in the standup's timezone. It has an optional reminder, sent 15 + minutes, 1 hour (the default) or 1 day before the close time, as long + as that is still ahead when the poll is sent. +4. The creator chooses **Named** or **Anonymous**, and who sees results: + **Everyone, after it closes** (the default) or **Managers only**. + Neither can be changed once the poll is sent. +5. A preview shows the Chat form as people will see it. **Send** sends the + poll; until then nothing is stored. + +Sending and answering (Chat) + +6. On sending, everyone active on the standup's roster gets a DM card + with the title, the intro, the close time and **Respond**. People on + vacation are skipped. Only the people asked can answer. +7. The standup's space gets a summary card: the title, who sent it, the + close time, and "3 of 8 answered". The count is redrawn at most once a + minute. +8. **Respond** opens a Chat form with every question in order. Required + questions say so. Submitting with a required question unanswered shows + "Answer “Which day suits you?”" in the form and keeps what was entered. +9. After submitting, the DM card shows "Answered at 14:02. You can change + your answers until 17:00 Asia/Kolkata." with **Change answers**, which + reopens the form with the previous answers filled in. +10. The poll's close time is shown in the standup's timezone everywhere, + such as "Fri 24 Oct, 17:00 Asia/Kolkata". + +Timer + +11. At the reminder time, everyone asked who hasn't answered gets one DM: + "⏰ Release retro closes at 17:00 Asia/Kolkata." with **Respond**. + Nobody gets more than one reminder. +12. At the close time the poll closes. A form opened before the close and + submitted after it is refused: "This poll closed at 17:00." Every DM + card and the space card redraw to "Closed". +13. A manager can close a poll early from the web app, with the same + effect. +14. Closing is idempotent and runs from the database, so a restart never + closes twice or skips a close. + +Results + +15. When a poll closes, the space gets a results post: + - **Everyone, after it closes**: for each choice question, a count per + option. For a named poll, the names follow each option. For each + text question, the number of answers. The post links to the web app + for the text answers. + - **Managers only**: "Release retro closed: 7 of 8 answered. Results + are with the standup's managers." +16. The web app shows results to anyone with `standups.configure` on the + standup: answered and not answered, a bar per option, and every text + answer. For an everyone-sees poll, people asked can also see results + after it closes. (Members hold `answers.view`, so that permission would + show results early and break **Managers only**.) +17. An anonymous poll never shows who answered what, to anyone, anywhere: + the web app, the space post, CSV, the API, MCP and webhooks. The web + app still shows how many have answered. +18. Managers can download results as CSV: one row per answer for a named + poll, and one row per answer without the person for an anonymous one. + +Integrations + +19. The API lists, creates, reads and closes polls, and exports CSV, with + the same permissions. MCP gets read-only tools to list polls and read + results. +20. A `poll_closed` webhook fires on close with the counts. It includes + names only for a named poll. + +## Non-goals + +- Creating these polls from Chat. The `poll` command stays as the quick + one-question poll. +- Answering through the API or MCP. +- Recurring or scheduled polls, and editing a poll after it is sent. +- Asking people outside the standup's roster, or people who join it after + the poll is sent. +- Live results while the poll is open. +- More than one reminder, and reminders in the space. +- Other question types: dates, ratings, file uploads. + +## Design impact + +| Area | Change | +| --- | --- | +| Web app | Standup **Polls** tab: open and closed polls, **New poll** builder with a preview, a results page with CSV download, and **Close now** | +| Chat cards and dialogs | DM poll card (open, answered, closed). Space summary card (open, closed). Response form with validation errors. Reminder DM. Results post. "Poll closed" error | +| API and OpenAPI | `GET/POST /standups/:id/question-polls`, `GET /question-polls/:id` (results by permission), `POST /question-polls/:id/close`, `GET /question-polls/:id/export.csv`. New schemas for polls, questions and results | +| MCP | `list_question_polls`, `get_question_poll_results` (read scope) | +| Database | Migration 21, both dialects: `question_polls` (standup, title, intro, questions as JSON, named or anonymous, results visibility, close time, reminder, creator, sent and closed times, space card name) and `question_poll_people` (poll, person, DM card name, reminded at, answers as JSON, answered and updated times) | +| Permissions | None new. Create, close, results and CSV: `standups.configure`. People asked see results after an everyone-sees poll closes | +| Webhooks | New `poll_closed` event | +| Docs | `user-guide.md` (answering a poll), `web-app.md` (Polls tab), `commands.md` (`polls` lists these too), `api.md`, `mcp.md`, `configuration.md` (webhook), `roles.md` | +| Website | `site/index.html`: a few words in the features list | +| Google APIs and scopes | None | + +How it works: + +- A new `QuestionPollService` in `src/core` creates, sends, records + answers, reminds and closes. The scheduler tick reminds and closes due + polls from the database, so ticks stay idempotent. +- Cards and the form go through `ChatAdapter`. The form is a Chat dialog, + which only the person who opened it can see. +- Checked against Google's Chat docs on 23 Oct 2026 (cards reference, + updated 18 Sep 2026; dialogs and form data, updated 9 Oct 2026): + - Text inputs, dropdowns, radio buttons and checkboxes all work in + dialogs. Each allows up to 100 options, and a card allows up to 100 + widgets and 32 KB, so 10 questions of 20 options fit in one form. + - Chat can only enforce required answers for text and dropdowns, not + radio buttons or checkboxes. AsyncUp validates every question itself, + and returns the error to the form with `INVALID_ARGUMENT`. + - A space accepts one message write a second, shared by every app in it, + which is why the space card is redrawn at most once a minute. + - Answers arrive as strings keyed by widget name, so each widget is + named after its question's position. +- Anonymous answers are stored with the person, as anonymous moods are, so + they can change their answers and reminders skip them. No view, export, + API, MCP tool or webhook returns the person for an anonymous poll. + Whoever runs the database can still link them, and the docs say so. + +## Open questions + +None. Decided with the maintainer on 23 Oct 2026: + +- A poll goes to a standup's roster by DM, with a summary card in its + space. +- Question types: short and long text, dropdown, single choice (radio) + and multiple choice (checkboxes), with a close time. +- Up to 10 questions per poll and 20 options per choice question. +- Results go to everyone after the poll closes, or to managers only. No + live results. +- Everyone active on the roster is asked, including people away that day. + Only people on vacation are skipped. diff --git a/src/adapters/fake/adapter.ts b/src/adapters/fake/adapter.ts index dd0f459..bb23db5 100644 --- a/src/adapters/fake/adapter.ts +++ b/src/adapters/fake/adapter.ts @@ -2,6 +2,7 @@ import type { ChatAdapter, SpaceInfo, SpaceMember, SpaceMembership, PromptState import type { Blocker, Poll, Run, RunSummary, Standup, Submission } from '../../core/types.js'; import type { TimeOffRequest } from '../../core/schedule.js'; import type { EditRequestView } from '../../core/edit-requests.js'; +import type { PollCardState, PollCounts, PollView } from '../../core/question-polls.js'; interface SentDm { kind: 'prompt' | 'reminder' | 'text' | 'blockerCard' | 'timeOffRequest' | 'editRequest' | 'editApproved'; @@ -17,12 +18,14 @@ interface SentDm { } interface PostedMessage { - kind: 'parent' | 'submission' | 'summary' | 'update' | 'text' | 'poll' | 'skip'; + kind: 'parent' | 'submission' | 'summary' | 'update' | 'text' | 'poll' | 'skip' | 'pollSummary'; spaceName: string; threadKey?: string; messageName?: string; text?: string; payload?: Submission | RunSummary | Poll; + counts?: PollCounts; + closed?: boolean; } /** @@ -42,8 +45,35 @@ export class FakeAdapter implements ChatAdapter { membershipError: Error | null = null; private messageCounter = 0; + /** Poll cards sent and redrawn, and space summaries redrawn, newest last. */ + pollCards: { userName: string; view: PollView; state: PollCardState; messageName: string }[] = []; + pollCardUpdates: { messageNames: string[]; view: PollView; state: PollCardState }[] = []; + pollSummaryUpdates: { messageName: string; counts: PollCounts; closed: boolean }[] = []; + constructor(private log: ((msg: string) => void) | null = null) {} + async sendPollCard(userName: string, view: PollView, state: PollCardState): Promise { + const messageName = `messages/fake-${++this.messageCounter}`; + this.pollCards.push({ userName, view, state, messageName }); + this.log?.(`DM poll "${view.title}" (${state.kind}) → ${userName}`); + return messageName; + } + + async updatePollCards(messageNames: string[], view: PollView, state: PollCardState): Promise { + this.pollCardUpdates.push({ messageNames, view, state }); + } + + async postPollSummary(standup: Standup, view: PollView, counts: PollCounts, closed: boolean): Promise { + const messageName = `messages/fake-${++this.messageCounter}`; + this.posts.push({ kind: 'pollSummary', spaceName: standup.spaceName, threadKey: `qpoll-${view.id}`, messageName, counts, closed }); + this.log?.(`Poll "${view.title}" → ${standup.spaceName}`); + return messageName; + } + + async updatePollSummary(messageName: string, _view: PollView, counts: PollCounts, closed: boolean): Promise { + this.pollSummaryUpdates.push({ messageName, counts, closed }); + } + async sendStandupPrompt(userName: string, standup: Standup, run: Run): Promise { this.dms.push({ kind: 'prompt', userName, standupId: standup.id, runId: run.id }); this.log?.(`DM prompt → ${userName} (standup "${standup.name}", ${run.date})`); diff --git a/src/adapters/gchat/adapter.ts b/src/adapters/gchat/adapter.ts index a1a548f..b72df3a 100644 --- a/src/adapters/gchat/adapter.ts +++ b/src/adapters/gchat/adapter.ts @@ -4,6 +4,7 @@ import type { SettingsService } from '../../core/settings.js'; import { chatEndpointUrl } from './addon.js'; import type { TimeOffRequest } from '../../core/schedule.js'; import type { EditRequestView } from '../../core/edit-requests.js'; +import type { PollCardState, PollCounts, PollView } from '../../core/question-polls.js'; import type { Repo } from '../../db/repo.js'; import type { Blocker, Poll, Run, RunSummary, Standup, Submission } from '../../core/types.js'; import { @@ -11,7 +12,9 @@ import { editApprovedCard, editRequestCard, timeOffRequestCard, + pollCardMessage, pollMessage, + pollSummaryMessage, promptMessage, reminderMessage, submissionMessage, @@ -155,6 +158,26 @@ export class GoogleChatAdapter implements ChatAdapter { } } + async sendPollCard(userName: string, poll: PollView, state: PollCardState): Promise { + const dm = await this.ensureDmSpace(userName); + const res = await (await this.getClient()).spaces.messages.create({ parent: dm, requestBody: pollCardMessage(poll, state, await this.endpoint()) }); + return res.data.name ?? null; + } + + async updatePollCards(messageNames: string[], poll: PollView, state: PollCardState): Promise { + const client = await this.getClient(); + const requestBody = pollCardMessage(poll, state, await this.endpoint()); + for (const name of messageNames) await client.spaces.messages.update({ name, updateMask: 'cardsV2', requestBody }); + } + + async postPollSummary(standup: Standup, poll: PollView, counts: PollCounts, closed: boolean): Promise { + return this.postInThread(standup.spaceName, `qpoll-${poll.id}`, pollSummaryMessage(poll, counts, closed)); + } + + async updatePollSummary(messageName: string, poll: PollView, counts: PollCounts, closed: boolean): Promise { + await (await this.getClient()).spaces.messages.update({ name: messageName, updateMask: 'cardsV2', requestBody: pollSummaryMessage(poll, counts, closed) }); + } + async postPoll(standup: Standup, poll: Poll, tallies: number[]): Promise { const res = await (await this.getClient()).spaces.messages.create({ parent: standup.spaceName, diff --git a/src/adapters/gchat/cards.ts b/src/adapters/gchat/cards.ts index f98c66f..84b5543 100644 --- a/src/adapters/gchat/cards.ts +++ b/src/adapters/gchat/cards.ts @@ -1,6 +1,7 @@ import type { PromptState } from '../../core/adapter.js'; import { dayLabel, untilLabel, type EditRequestView } from '../../core/edit-requests.js'; import { toCardHtml } from '../../core/format.js'; +import { POLL_LIMITS, type PollAnswer, type PollCardState, type PollCounts, type PollView } from '../../core/question-polls.js'; import { describeDates, type TimeOffRequest } from '../../core/schedule.js'; import { DateTime } from 'luxon'; import { LIMITS } from '../../core/validation.js'; @@ -37,6 +38,8 @@ export const SUBMIT_EDIT_REQUEST_FN = 'submitEditRequest'; export const APPROVE_EDIT_FN = 'approveEdit'; export const DECLINE_EDIT_FN = 'declineEdit'; export const SUBMIT_EDIT_DECLINE_FN = 'submitDeclineEdit'; +export const OPEN_POLL_FORM_FN = 'openPollForm'; +export const SUBMIT_POLL_FORM_FN = 'submitPollForm'; function humanDate(isoDate: string): string { return DateTime.fromISO(isoDate).toFormat('ccc, dd LLL yyyy'); @@ -676,3 +679,87 @@ export function editApprovedCard(request: EditRequestView, endpoint: string | nu ], }; } + +const pollParam = (poll: PollView) => [{ key: 'pollId', value: String(poll.id) }]; + +/** DM card for a poll: to answer, as a reminder, once answered, and once closed. */ +export function pollCardMessage(poll: PollView, state: PollCardState, endpoint: string | null = null) { + const time = (iso: string) => DateTime.fromISO(iso).setZone(poll.zone).toFormat('HH:mm'); + const respond = (text: string) => ({ buttonList: { buttons: [{ text, onClick: { action: clickAction(OPEN_POLL_FORM_FN, pollParam(poll), endpoint, 'OPEN_DIALOG') } }] } }); + const count = `${poll.questions.length} question${poll.questions.length === 1 ? '' : 's'} · closes ${poll.closesLabel}`; + const privacy = poll.anonymous ? 'Anonymous: nobody sees who answered what.' : `Named: ${html(poll.from)} sees who answered what.`; + const widgets: any[] = []; + if (state.kind === 'open') { + if (poll.intro) widgets.push({ textParagraph: { text: html(poll.intro) } }); + widgets.push({ textParagraph: { text: `${count}
${privacy}` } }, respond('Respond')); + } else if (state.kind === 'reminder') { + widgets.push({ textParagraph: { text: `You haven’t answered yet. It closes at ${poll.closesTime}.` } }, respond('Respond')); + } else if (state.kind === 'answered') { + widgets.push({ textParagraph: { text: `✅ Answered at ${time(state.at)}. You can change your answers until ${poll.closesTime}.` } }, respond('Change answers')); + } else { + widgets.push({ textParagraph: { text: `🔒 Closed at ${poll.closesTime}.${state.answeredAt ? ` You answered at ${time(state.answeredAt)}.` : ''}` } }); + } + return { + cardsV2: [ + { + cardId: `question-poll-${poll.id}`, + card: { + header: { title: `${state.kind === 'reminder' ? '⏰' : '📋'} ${poll.title}`, subtitle: `From ${poll.from} · ${poll.standupName}` }, + sections: [{ widgets }], + }, + }, + ], + }; +} + +/** The card that starts the poll's thread in the standup's space. */ +export function pollSummaryMessage(poll: PollView, counts: PollCounts, closed: boolean) { + const text = closed + ? `🔒 Closed: ${counts.answered} of ${counts.asked} answered. ${poll.results === 'everyone' ? 'Results are in the thread.' : 'Results are with the standup’s managers.'}` + : `${counts.answered} of ${counts.asked} answered · closes ${poll.closesLabel}
${poll.results === 'everyone' ? 'Everyone sees the results after it closes.' : 'Results go to the standup’s managers.'}`; + return { + cardsV2: [ + { + cardId: `question-poll-summary-${poll.id}`, + card: { + header: { title: `📋 ${poll.title} · poll`, subtitle: `Sent by ${poll.from} to the people on ${poll.standupName}` }, + sections: [{ widgets: [{ textParagraph: { text } }] }], + }, + }, + ], + }; +} + +/** The form behind Respond: one input per question, named q0..qN, filled in with earlier answers. */ +export function pollFormDialog(poll: PollView, answers: PollAnswer[] | null, endpoint: string | null = null, error?: string) { + const label = (i: number) => `${poll.questions[i]!.text}${poll.questions[i]!.required ? ' (required)' : ''}`; + const picked = (i: number) => { + const a = answers?.[i]; + return Array.isArray(a) ? a : []; + }; + const widgets: any[] = [ + ...(error ? [errorLine(error)] : []), + { textParagraph: { text: `${html(poll.title)}${poll.intro ? `
${html(poll.intro)}` : ''}
Closes ${poll.closesLabel}. ${poll.anonymous ? 'Anonymous: nobody sees who answered what.' : `Named: ${html(poll.from)} sees who answered what.`}` } }, + ...poll.questions.map((q, i) => { + const name = `q${i}`; + if (q.type === 'short' || q.type === 'long') { + const a = answers?.[i]; + return { + textInput: { + name, + label: label(i), + type: q.type === 'long' ? 'MULTIPLE_LINE' : 'SINGLE_LINE', + value: typeof a === 'string' && a ? a : undefined, + hintText: `Up to ${(q.type === 'long' ? POLL_LIMITS.long : POLL_LIMITS.short).toLocaleString('en-GB')} characters.`, + }, + }; + } + const items = q.options.map((text, n) => ({ text, value: String(n), selected: picked(i).includes(n) })); + const type = q.type === 'dropdown' ? 'DROPDOWN' : q.type === 'single' ? 'RADIO_BUTTON' : 'CHECK_BOX'; + // A dropdown always shows a choice, so an unanswered one starts on a blank entry. + return { selectionInput: { name, label: label(i), type, items: q.type === 'dropdown' ? [{ text: 'Choose one', value: '', selected: picked(i).length === 0 }, ...items] : items } }; + }), + { buttonList: { buttons: [{ text: 'Submit', onClick: { action: clickAction(SUBMIT_POLL_FORM_FN, pollParam(poll), endpoint) } }] } }, + ]; + return { actionResponse: { type: 'DIALOG', dialogAction: { dialog: { body: { sections: [{ widgets }] } } } } }; +} diff --git a/src/adapters/gchat/events.ts b/src/adapters/gchat/events.ts index de373e3..9749f65 100644 --- a/src/adapters/gchat/events.ts +++ b/src/adapters/gchat/events.ts @@ -46,6 +46,9 @@ import { SUBMIT_BLOCKER_UPDATE_FN, SUBMIT_DIALOG_FN, VOTE_POLL_FN, + OPEN_POLL_FORM_FN, + SUBMIT_POLL_FORM_FN, + pollFormDialog, } from './cards.js'; /** @@ -314,6 +317,20 @@ export class EventRouter { return dialogOk(await this.blockers.tag(standup, blocker.id, mentions, user)); } + if (fn === OPEN_POLL_FORM_FN || fn === SUBMIT_POLL_FORM_FN) { + const pollId = Number(getParameter(event, 'pollId')) || 0; + const polls = this.service.questionPolls; + const opened = await polls.form(pollId, user.userName); + if (!opened.ok) return dialogError(opened.message); + if (fn === OPEN_POLL_FORM_FN) return pollFormDialog(opened.view, opened.answers, await this.endpoint()); + // Choices arrive as option indexes; an empty dropdown entry means no answer. + const raw = opened.view.questions.map((q, i) => + q.type === 'short' || q.type === 'long' ? getFormValue(event, `q${i}`) : getFormValues(event, `q${i}`).filter((v) => v !== '').map(Number), + ); + const result = await polls.answer(pollId, user, raw); + return result.ok ? dialogOk(result.message) : pollFormDialog(opened.view, raw, await this.endpoint(), result.message); + } + if (fn === VOTE_POLL_FN && this.polls) { const pollId = Number(getParameter(event, 'pollId')); const optionIndex = Number(getParameter(event, 'optionIndex')); diff --git a/src/api/api.ts b/src/api/api.ts index fdb1f0c..1b86f1f 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -14,6 +14,7 @@ import type { ScheduleService } from '../core/schedule.js'; import type { Repo } from '../db/repo.js'; import { registerBlockerRoutes } from './blockers.js'; import { registerEditRequestRoutes } from './edit-requests.js'; +import { registerQuestionPollRoutes } from './question-polls.js'; import { registerMcpRoutes } from './mcp.js'; import { registerMemberRoutes } from './member.js'; import { registerScheduleRoutes } from './schedule.js'; @@ -127,6 +128,7 @@ export function registerApi(app: Express, deps: ApiDeps): void { registerMemberRoutes(api, ctx); registerScheduleRoutes(api, ctx); registerEditRequestRoutes(api, ctx); + registerQuestionPollRoutes(api, ctx); registerSettingsRoutes(api, ctx); registerSpaceRoutes(api, ctx); registerMcpRoutes(api, ctx); diff --git a/src/api/question-polls.ts b/src/api/question-polls.ts new file mode 100644 index 0000000..a108661 --- /dev/null +++ b/src/api/question-polls.ts @@ -0,0 +1,141 @@ +import type { Request, Response, Router } from 'express'; +import type { PollDraft, QuestionPoll, QuestionPolls } from '../core/question-polls.js'; +import type { Standup } from '../core/types.js'; +import type { Principal } from './principal.js'; +import { allowed, apiError, loadStandup, principalOf, type ApiContext } from './shared.js'; + +/** A poll as lists show it. */ +export async function pollSummaryView(polls: QuestionPolls, poll: QuestionPoll, standup: Standup) { + const people = await polls.people(poll.id); + return { + id: poll.id, + standupId: poll.standupId, + title: poll.title, + from: poll.createdDisplay, + createdAt: poll.createdAt, + closesAt: poll.closesAt, + closesLabel: polls.labelOf(poll.closesAt, standup), + closedAt: poll.closedAt, + closedLabel: poll.closedAt ? polls.labelOf(poll.closedAt, standup) : null, + open: !polls.isClosed(poll), + anonymous: poll.anonymous, + results: poll.results, + asked: people.length, + answered: people.filter((x) => x.answeredAt).length, + }; +} + +/** + * Results go to the standup's managers (members can read answers too, so + * that permission would leak results early), and, once an everyone-sees poll + * has closed, to the people it asked. An anonymous poll never names anyone. + */ +export async function pollDetailView(polls: QuestionPolls, poll: QuestionPoll, standup: Standup, p: Principal) { + const asked = (await polls.people(poll.id)).some((x) => x.userName === p.user?.userName); + const canSee = allowed(p, 'standups.configure', standup.id) || (asked && poll.results === 'everyone' && polls.isClosed(poll)); + return { + ...(await pollSummaryView(polls, poll, standup)), + intro: poll.intro, + questions: poll.questions, + remindAt: poll.remindAt, + outcome: canSee ? await outcomeOf(polls, poll) : null, + }; +} + +async function outcomeOf(polls: QuestionPolls, poll: QuestionPoll) { + const r = (await polls.results(poll.id, true))!; + return { notAnswered: r.notAnswered, questions: r.questions }; +} + +/** Finds a poll the caller may see, or answers the request. */ +export async function findPoll(ctx: ApiContext, p: Principal, id: number): Promise<{ poll: QuestionPoll; standup: Standup } | null> { + const found = await ctx.service?.questionPolls.get(id); + if (!found || found.standup.tenantId !== p.tenantId || !allowed(p, 'standups.view', found.standup.id)) return null; + return found; +} + +/** Polls with questions: managers send and close them; results follow `pollDetailView`. */ +export function registerQuestionPollRoutes(api: Router, ctx: ApiContext): void { + const service = (res: Response) => { + if (!ctx.service) apiError(res, 503, 'unavailable', 'Polls are not available on this install.'); + return ctx.service?.questionPolls ?? null; + }; + const poll = async (req: Request, res: Response) => { + const found = await findPoll(ctx, principalOf(req), Number(req.params.id) || 0); + if (!found) apiError(res, 404, 'not_found', 'No such poll.'); + return found; + }; + + api.get('/standups/:id/question-polls', async (req, res) => { + const polls = service(res); + if (!polls) return; + const standup = await loadStandup(ctx, req, res, { need: 'standups.view' }); + if (!standup) return; + const list = await polls.list(standup.id); + res.json({ polls: await Promise.all(list.map((x) => pollSummaryView(polls, x, standup))) }); + }); + + api.post('/standups/:id/question-polls', async (req, res) => { + const polls = service(res); + if (!polls) return; + const standup = await loadStandup(ctx, req, res, { need: 'standups.configure' }); + if (!standup) return; + const p = principalOf(req); + const body = req.body ?? {}; + const draft: PollDraft = { + title: body.title, + intro: body.intro ?? null, + questions: Array.isArray(body.questions) ? body.questions.map((q: any) => ({ text: q?.text, type: q?.type, required: q?.required === true, options: Array.isArray(q?.options) ? q.options : [] })) : [], + closesAt: body.closesAt, + remindMinutes: body.remindMinutes ?? null, + anonymous: body.anonymous === true, + results: body.results ?? 'everyone', + }; + const result = await polls.create(standup, draft, { userName: p.user?.userName ?? 'operator', displayName: p.user?.name ?? 'Operator' }); + if (!result.ok) { + apiError(res, 400, 'invalid', result.message, result.field); + return; + } + res.status(201).json(await pollDetailView(polls, result.poll, standup, p)); + }); + + api.get('/question-polls/:id', async (req, res) => { + const polls = service(res); + if (!polls) return; + const found = await poll(req, res); + if (!found) return; + res.json(await pollDetailView(polls, found.poll, found.standup, principalOf(req))); + }); + + api.post('/question-polls/:id/close', async (req, res) => { + const polls = service(res); + if (!polls) return; + const found = await poll(req, res); + if (!found) return; + const p = principalOf(req); + if (!allowed(p, 'standups.configure', found.standup.id)) { + apiError(res, 403, 'forbidden', `Only the managers of ${found.standup.name} can close it.`); + return; + } + if ((await polls.close(found.poll.id, null)) !== 'closed') { + apiError(res, 409, 'closed', 'This poll has already closed.'); + return; + } + res.json(await pollDetailView(polls, (await polls.get(found.poll.id))!.poll, found.standup, p)); + }); + + api.get('/question-polls/:id/export.csv', async (req, res) => { + const polls = service(res); + if (!polls) return; + const found = await poll(req, res); + if (!found) return; + if (!allowed(principalOf(req), 'standups.configure', found.standup.id)) { + apiError(res, 403, 'forbidden', `Only the managers of ${found.standup.name} can export its polls.`); + return; + } + res + .header('content-type', 'text/csv; charset=utf-8') + .header('content-disposition', `attachment; filename="poll-${found.poll.id}.csv"`) + .send(await polls.csv(found.poll.id)); + }); +} diff --git a/src/api/spec/routes.ts b/src/api/spec/routes.ts index 5ef1993..726da96 100644 --- a/src/api/spec/routes.ts +++ b/src/api/spec/routes.ts @@ -30,6 +30,7 @@ const standupId = { id: { schema: z.number().int().positive(), description: 'Sta const blockerId = { id: { schema: z.number().int().positive(), description: 'Blocker id' } }; const userNameParam = { userName: { schema: z.string(), description: 'Chat user resource name, URL-encoded (users%2F123)' } }; const dateParam = { date: { schema: z.iso.date(), description: 'YYYY-MM-DD' } }; +const pollId = { id: { schema: z.number().int().positive(), description: 'Poll id' } }; export const ROUTES: RouteSpec[] = [ { method: 'get', path: '/openapi.json', summary: 'This OpenAPI document', tag: 'Meta', access: 'public', responses: { 200: z.record(z.string(), z.unknown()) } }, @@ -204,6 +205,13 @@ export const ROUTES: RouteSpec[] = [ errors: [404, 409, 503], })), + // polls with questions + { method: 'get', path: '/standups/:id/question-polls', summary: 'Polls with questions sent to this standup, newest first', tag: 'Polls', access: 'any', permission: 'standups.view', params: standupId, responses: { 200: S.QuestionPolls }, errors: [404, 503] }, + { method: 'post', path: '/standups/:id/question-polls', summary: 'Send a poll with questions to the standup’s roster', tag: 'Polls', access: 'manager', permission: 'standups.configure', params: standupId, body: S.PollDraft, responses: { 201: S.QuestionPoll }, errors: [400, 404, 503] }, + { method: 'get', path: '/question-polls/:id', summary: 'One poll, with results when the caller may see them', tag: 'Polls', access: 'any', permission: 'standups.view', params: pollId, responses: { 200: S.QuestionPoll }, errors: [404, 503] }, + { method: 'post', path: '/question-polls/:id/close', summary: 'Close a poll now', tag: 'Polls', access: 'manager', permission: 'standups.configure', params: pollId, responses: { 200: S.QuestionPoll }, errors: [404, 409, 503] }, + { method: 'get', path: '/question-polls/:id/export.csv', summary: 'A poll’s answers as CSV, without names on an anonymous poll', tag: 'Polls', access: 'manager', permission: 'standups.configure', params: pollId, responses: { 200: 'csv' }, errors: [404, 503] }, + // settings { method: 'get', path: '/settings', summary: 'Workspace settings', tag: 'Settings', access: 'admin', permission: 'settings.manage', responses: { 200: S.Settings } }, { method: 'patch', path: '/settings', summary: 'Change workspace settings', tag: 'Settings', access: 'admin', permission: 'settings.manage', body: S.SettingsPatch, responses: { 200: S.Settings }, errors: [400, 409] }, diff --git a/src/api/spec/schemas.ts b/src/api/spec/schemas.ts index f172149..bcdff68 100644 --- a/src/api/spec/schemas.ts +++ b/src/api/spec/schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { QUESTION_TYPES, RESULTS_VISIBILITY } from '../../core/poll-types.js'; import { MCP_SCOPES } from '../../core/mcp-scopes.js'; import { PERMISSIONS } from '../../core/permission-catalogue.js'; import { AWAY_REASONS, MOODS, OVERRIDE_STATUSES, TIME_OFF_POLICIES, WEEKDAYS } from '../../core/types.js'; @@ -404,6 +405,67 @@ export const EditRequest = named( ); export const EditRequests = named('EditRequests', z.strictObject({ requests: z.array(EditRequest) })); +// --- polls with questions + +const QuestionType = named('PollQuestionType', z.enum(QUESTION_TYPES), 'short and long are text; dropdown and single take one option; multiple takes any number.'); +const ResultsFor = named('PollResultsFor', z.enum(RESULTS_VISIBILITY), 'Who sees the results: everyone asked, after it closes, or the standup’s managers only.'); +export const PollQuestion = named('PollQuestion', z.strictObject({ text: z.string(), type: QuestionType, required: z.boolean(), options: z.array(z.string()) })); +export const PollDraft = named( + 'PollDraft', + z.strictObject({ + title: z.string(), + intro: z.string().nullable().optional(), + questions: z.array(z.strictObject({ text: z.string(), type: QuestionType, required: z.boolean().optional(), options: z.array(z.string()).optional() })), + closesAt: z.string().describe('Local date and time in the standup’s timezone, YYYY-MM-DDTHH:mm, from 15 minutes to 14 days ahead.'), + remindMinutes: z.union([z.literal(15), z.literal(60), z.literal(1440)]).nullable().optional(), + anonymous: z.boolean().optional(), + results: ResultsFor.optional(), + }), + 'Up to 10 questions; choice questions have 2 to 20 options. Named or anonymous and who sees results cannot change once sent.', +); +const PollSummaryShape = { + id, + standupId: id, + title: z.string(), + from: z.string(), + createdAt: timestamp, + closesAt: timestamp, + closesLabel: z.string(), + closedAt: timestamp.nullable(), + closedLabel: z.string().nullable(), + open: z.boolean(), + anonymous: z.boolean(), + results: ResultsFor, + asked: count, + answered: count, +}; +export const QuestionPollSummary = named('QuestionPollSummary', z.strictObject(PollSummaryShape)); +export const QuestionPolls = named('QuestionPolls', z.strictObject({ polls: z.array(QuestionPollSummary) })); +export const QuestionPoll = named( + 'QuestionPoll', + z.strictObject({ + ...PollSummaryShape, + intro: z.string().nullable(), + questions: z.array(PollQuestion), + remindAt: timestamp.nullable(), + outcome: z + .strictObject({ + notAnswered: z.array(z.string()).nullable(), + questions: z.array( + z.strictObject({ + text: z.string(), + type: QuestionType, + answered: count, + options: z.array(z.strictObject({ text: z.string(), count, names: z.array(z.string()).nullable() })), + answers: z.array(z.strictObject({ name: z.string().nullable(), text: z.string() })), + }), + ), + }) + .nullable() + .describe('Null unless the caller may see results. Names are null on an anonymous poll.'), + }), +); + // --- settings, verification and MCP const secret = z.strictObject({ set: z.boolean() }); @@ -682,3 +744,7 @@ export type PermissionInfo = z.infer['permissions'][numbe export type Role = z.infer; export type Grant = z.infer; export type RoleAuditEntry = z.infer['entries'][number]; +export type PollQuestion = z.infer; +export type PollDraft = z.infer; +export type QuestionPollSummary = z.infer; +export type QuestionPoll = z.infer; diff --git a/src/core/adapter.ts b/src/core/adapter.ts index e9605ec..b9a7958 100644 --- a/src/core/adapter.ts +++ b/src/core/adapter.ts @@ -1,6 +1,7 @@ import type { Blocker, Poll, Run, RunSummary, Standup, Submission } from './types.js'; import type { TimeOffRequest } from './schedule.js'; import type { EditRequestView, EditState } from './edit-requests.js'; +import type { PollCardState, PollCounts, PollView } from './question-polls.js'; /** A space the app has been added to. */ export interface SpaceInfo { @@ -99,6 +100,18 @@ export interface ChatAdapter { */ postPoll(standup: Standup, poll: Poll, tallies: number[]): Promise; + /** DM someone a poll card (to answer, or a reminder); returns the card's message name when known. */ + sendPollCard(userName: string, poll: PollView, state: PollCardState): Promise; + + /** Redraw the poll cards already sent to someone, once they answer or the poll closes. */ + updatePollCards(messageNames: string[], poll: PollView, state: PollCardState): Promise; + + /** Start the poll's thread in the standup's space with its summary card; returns the message name. */ + postPollSummary(standup: Standup, poll: PollView, counts: PollCounts, closed: boolean): Promise; + + /** Redraw the summary card with a new count, or as closed. */ + updatePollSummary(messageName: string, poll: PollView, counts: PollCounts, closed: boolean): Promise; + /** Spaces the app is a member of — the create-standup space picker. */ listSpaces(): Promise; diff --git a/src/core/commands.ts b/src/core/commands.ts index e3982ea..03c13b6 100644 --- a/src/core/commands.ts +++ b/src/core/commands.ts @@ -698,15 +698,29 @@ export class CommandHandler { private async pollList(standup: Standup): Promise { const open = await this.repo.listOpenPolls(standup.id); - if (open.length === 0) { + const questionPolls = this.standups?.questionPolls; + const asked = questionPolls ? (await questionPolls.list(standup.id)).filter((p) => !questionPolls.isClosed(p)) : []; + if (open.length === 0 && asked.length === 0) { return 'No open polls. Start one with `poll Question? | Option A | Option B`.'; } - const lines: string[] = []; - for (const poll of open) { - const total = (await this.repo.listPollVotes(poll.id)).length; - lines.push(`#${poll.id} ${poll.question} — ${total} vote${total === 1 ? '' : 's'} (by ${poll.createdDisplay})`); + const parts: string[] = []; + if (open.length) { + const lines: string[] = []; + for (const poll of open) { + const total = (await this.repo.listPollVotes(poll.id)).length; + lines.push(`#${poll.id} ${poll.question} — ${total} vote${total === 1 ? '' : 's'} (by ${poll.createdDisplay})`); + } + parts.push(`*Open polls:*\n${lines.join('\n')}\n\`poll results\` · \`poll close\``); + } + if (asked.length) { + const lines: string[] = []; + for (const poll of asked) { + const people = await questionPolls!.people(poll.id); + lines.push(`📋 ${poll.title} — ${people.filter((p) => p.answeredAt).length} of ${people.length} answered, closes ${questionPolls!.labelOf(poll.closesAt, standup)} (by ${poll.createdDisplay})`); + } + parts.push(`*Polls with questions:*\n${lines.join('\n')}\nResults are in the web app, under ${standup.name} › Polls.`); } - return `*Open polls:*\n${lines.join('\n')}\n\`poll results\` · \`poll close\``; + return parts.join('\n\n'); } private exportInfo(standup: Standup): string { diff --git a/src/core/poll-types.ts b/src/core/poll-types.ts new file mode 100644 index 0000000..6943403 --- /dev/null +++ b/src/core/poll-types.ts @@ -0,0 +1,18 @@ +/** Poll limits and question types; no imports, so the JSON API's schemas can use them. */ + +export const QUESTION_TYPES = ['short', 'long', 'dropdown', 'single', 'multiple'] as const; +export type QuestionType = (typeof QUESTION_TYPES)[number]; +export const RESULTS_VISIBILITY = ['everyone', 'managers'] as const; +export type ResultsVisibility = (typeof RESULTS_VISIBILITY)[number]; +/** Minutes before the close a reminder may go: 15 minutes, 1 hour or 1 day. */ +export const REMINDER_MINUTES = [15, 60, 1440] as const; + +export const POLL_LIMITS = { title: 100, intro: 500, questions: 10, question: 200, options: 20, option: 80, short: 200, long: 1000 } as const; + +export interface PollQuestion { + text: string; + type: QuestionType; + required: boolean; + /** Choice questions only; empty for text questions. */ + options: string[]; +} diff --git a/src/core/question-polls.ts b/src/core/question-polls.ts new file mode 100644 index 0000000..38dac6c --- /dev/null +++ b/src/core/question-polls.ts @@ -0,0 +1,431 @@ +import { DateTime } from 'luxon'; +import type { ChatAdapter } from './adapter.js'; +import type { Mention } from './commands.js'; +import type { Standup } from './types.js'; +import type { WebhookNotifier } from './webhooks.js'; +import type { Repo } from '../db/repo.js'; + +export { POLL_LIMITS, QUESTION_TYPES, REMINDER_MINUTES, RESULTS_VISIBILITY, type PollQuestion, type QuestionType, type ResultsVisibility } from './poll-types.js'; +import { POLL_LIMITS, QUESTION_TYPES, REMINDER_MINUTES, RESULTS_VISIBILITY, type PollQuestion, type QuestionType, type ResultsVisibility } from './poll-types.js'; + +/** What the builder sends. `closesAt` is a local date and time in the standup's timezone. */ +export interface PollDraft { + title: string; + intro: string | null; + questions: PollQuestion[]; + closesAt: string; + remindMinutes: number | null; + anonymous: boolean; + results: ResultsVisibility; +} + +/** One answer per question: text, the option indexes picked, or null when left empty. */ +export type PollAnswer = string | number[] | null; + +export interface QuestionPoll { + id: number; + standupId: number; + title: string; + intro: string | null; + questions: PollQuestion[]; + anonymous: boolean; + results: ResultsVisibility; + closesAt: string; + remindAt: string | null; + remindedAt: string | null; + createdBy: string; + createdDisplay: string; + createdAt: string; + closedAt: string | null; + spaceMessage: string | null; + /** The answered count the space card last showed, and when. */ + spaceCount: number; + spaceDrawnAt: string | null; +} + +export interface PollPerson { + pollId: number; + userName: string; + displayName: string; + cardMessages: string[]; + remindedAt: string | null; + answers: PollAnswer[] | null; + answeredAt: string | null; +} + +/** What cards and dialogs show about a poll. */ +export interface PollView { + id: number; + standupId: number; + standupName: string; + title: string; + intro: string | null; + from: string; + questions: PollQuestion[]; + /** "Wed 10 Jun, 17:00 Asia/Kolkata" */ + closesLabel: string; + /** "17:00 Asia/Kolkata" */ + closesTime: string; + anonymous: boolean; + results: ResultsVisibility; + zone: string; +} + +export type PollCardState = { kind: 'open' } | { kind: 'reminder' } | { kind: 'answered'; at: string } | { kind: 'closed'; answeredAt: string | null }; + +export interface PollCounts { + asked: number; + answered: number; +} + +export interface OptionResult { + text: string; + count: number; + /** Null on an anonymous poll, or when names are withheld from the caller. */ + names: string[] | null; +} + +export interface QuestionResult { + text: string; + type: QuestionType; + answered: number; + options: OptionResult[]; + /** Text answers, in the order given; `name` is null when withheld. */ + answers: { name: string | null; text: string }[]; +} + +export type DraftCheck = { ok: true; value: Omit } | { ok: false; field: string; message: string }; +export type CreateResult = { ok: true; poll: QuestionPoll } | { ok: false; field: string; message: string }; +export type FormResult = { ok: true; view: PollView; answers: PollAnswer[] | null } | { ok: false; message: string }; +export type AnswerResult = { ok: true; message: string } | { ok: false; message: string; missing?: string }; + +const isChoice = (type: QuestionType) => type === 'dropdown' || type === 'single' || type === 'multiple'; + +/** + * One-off polls with several questions, sent by DM to a standup's roster, + * that close at a set time. The scheduler's tick reminds, redraws the space + * card and closes from the database, so a restart never repeats or skips one. + */ +export class QuestionPolls { + constructor( + private repo: Repo, + private adapter: ChatAdapter, + private now: () => DateTime, + private log: (msg: string) => void, + private webhooks: WebhookNotifier | null = null, + ) {} + + /** Checks a draft against the limits and turns it into what is stored. */ + check(standup: Standup, draft: PollDraft): DraftCheck { + const title = typeof draft.title === 'string' ? draft.title.trim() : ''; + if (!title) return { ok: false, field: 'title', message: 'Give the poll a title.' }; + if (title.length > POLL_LIMITS.title) return { ok: false, field: 'title', message: `Keep the title to ${POLL_LIMITS.title} characters.` }; + const intro = typeof draft.intro === 'string' && draft.intro.trim() ? draft.intro.trim() : null; + if (intro && intro.length > POLL_LIMITS.intro) return { ok: false, field: 'intro', message: `Keep the intro to ${POLL_LIMITS.intro} characters.` }; + const raw = Array.isArray(draft.questions) ? draft.questions : []; + if (raw.length < 1 || raw.length > POLL_LIMITS.questions) return { ok: false, field: 'questions', message: `Add 1 to ${POLL_LIMITS.questions} questions.` }; + const questions: PollQuestion[] = []; + for (const [i, q] of raw.entries()) { + const field = `questions.${i}`; + const n = i + 1; + const text = typeof q?.text === 'string' ? q.text.trim() : ''; + if (!QUESTION_TYPES.includes(q?.type)) return { ok: false, field, message: `Question ${n} has an unknown type.` }; + if (!text || text.length > POLL_LIMITS.question) return { ok: false, field, message: `Question ${n} needs its text, up to ${POLL_LIMITS.question} characters.` }; + const options = isChoice(q.type) ? (Array.isArray(q.options) ? q.options : []).map((o) => (typeof o === 'string' ? o.trim() : '')).filter(Boolean) : []; + if (isChoice(q.type)) { + if (options.length < 2 || options.length > POLL_LIMITS.options) return { ok: false, field, message: `Question ${n} needs 2 to ${POLL_LIMITS.options} options.` }; + if (options.some((o) => o.length > POLL_LIMITS.option)) return { ok: false, field, message: `Keep each option to ${POLL_LIMITS.option} characters.` }; + const seen = new Set(); + for (const o of options) { + if (seen.has(o.toLowerCase())) return { ok: false, field, message: `Question ${n} has the option “${o}” twice.` }; + seen.add(o.toLowerCase()); + } + } + questions.push({ text, type: q.type, required: q.required === true, options }); + } + const closes = typeof draft.closesAt === 'string' ? DateTime.fromISO(draft.closesAt, { zone: standup.timezone }) : DateTime.invalid('missing'); + const now = this.now(); + if (!closes.isValid || closes < now.plus({ minutes: 15 }) || closes > now.plus({ days: 14 })) { + return { ok: false, field: 'closesAt', message: 'Close it between 15 minutes and 14 days from now.' }; + } + let remindAt: string | null = null; + if (draft.remindMinutes !== null && draft.remindMinutes !== undefined) { + if (!(REMINDER_MINUTES as readonly number[]).includes(draft.remindMinutes)) { + return { ok: false, field: 'remindMinutes', message: 'Remind 15 minutes, 1 hour or 1 day before, or not at all.' }; + } + const at = closes.minus({ minutes: draft.remindMinutes }); + if (at <= now) return { ok: false, field: 'remindMinutes', message: 'That reminder would be in the past. Choose a shorter one, or none.' }; + remindAt = at.toUTC().toISO()!; + } + if (!RESULTS_VISIBILITY.includes(draft.results)) return { ok: false, field: 'results', message: 'Results go to everyone after it closes, or to managers only.' }; + return { ok: true, value: { title, intro, questions, anonymous: draft.anonymous === true, results: draft.results, closesAt: closes.toUTC().toISO()!, remindAt } }; + } + + /** Sends a poll to everyone active on the roster except people on vacation. */ + async create(standup: Standup, draft: PollDraft, creator: Mention): Promise { + const checked = this.check(standup, draft); + if (!checked.ok) return checked; + const people = (await this.repo.listParticipants(standup.id)).filter((p) => !p.onVacation); + if (people.length === 0) return { ok: false, field: 'standupId', message: `Nobody on ${standup.name} can be asked: everyone is on vacation or the roster is empty.` }; + const at = this.now().toUTC().toISO()!; + const poll = await this.repo.createQuestionPoll({ ...checked.value, standupId: standup.id, createdBy: creator.userName, createdDisplay: creator.displayName, createdAt: at }, people); + const view = this.viewOf(poll, standup); + for (const p of people) { + try { + const name = await this.adapter.sendPollCard(p.userName, view, { kind: 'open' }); + if (name) await this.repo.addQuestionPollCard(poll.id, p.userName, name); + } catch (err) { + this.log(`poll ${poll.id} to ${p.userName} failed: ${err}`); + } + } + try { + const name = await this.adapter.postPollSummary(standup, view, { asked: people.length, answered: 0 }, false); + if (name) await this.repo.setQuestionPollSpace(poll.id, name, 0, at); + } catch (err) { + this.log(`poll ${poll.id} summary failed: ${err}`); + } + return { ok: true, poll: (await this.repo.getQuestionPoll(poll.id))! }; + } + + async list(standupId: number): Promise { + return this.repo.listQuestionPolls(standupId); + } + + async get(id: number): Promise<{ poll: QuestionPoll; standup: Standup } | null> { + const poll = await this.repo.getQuestionPoll(id); + if (!poll) return null; + const standup = await this.repo.getStandupById(poll.standupId); + return standup ? { poll, standup } : null; + } + + async people(id: number): Promise { + return this.repo.listQuestionPollPeople(id); + } + + /** What the Chat form needs to open: the poll, and the person's earlier answers. */ + async form(id: number, userName: string): Promise { + const found = await this.get(id); + if (!found) return { ok: false, message: 'This poll no longer exists.' }; + const { poll, standup } = found; + const view = this.viewOf(poll, standup); + if (this.isClosed(poll)) return { ok: false, message: `This poll closed at ${view.closesTime}.` }; + const person = (await this.repo.listQuestionPollPeople(id)).find((p) => p.userName === userName); + if (!person) return { ok: false, message: 'This poll wasn’t sent to you.' }; + return { ok: true, view, answers: person.answers }; + } + + /** Records someone's answers, or says what is wrong; `missing` names a required question left empty. */ + async answer(id: number, user: Mention, raw: PollAnswer[]): Promise { + const opened = await this.form(id, user.userName); + if (!opened.ok) return opened; + const { view } = opened; + const answers: PollAnswer[] = []; + for (const [i, q] of view.questions.entries()) { + const a = raw[i]; + if (isChoice(q.type)) { + const picked = Array.isArray(a) ? [...new Set(a)].sort((x, y) => x - y) : []; + if (picked.some((n) => !Number.isInteger(n) || n < 0 || n >= q.options.length)) return { ok: false, message: 'That option no longer exists.' }; + if (q.type !== 'multiple' && picked.length > 1) return { ok: false, message: `Pick one answer for “${q.text}”.` }; + if (q.required && picked.length === 0) return { ok: false, message: `Answer “${q.text}”`, missing: q.text }; + answers.push(q.type === 'multiple' ? picked : (picked.length ? picked : null)); + } else { + const text = typeof a === 'string' ? a.trim() : ''; + const max = q.type === 'long' ? POLL_LIMITS.long : POLL_LIMITS.short; + if (text.length > max) return { ok: false, message: `Keep “${q.text}” to ${max.toLocaleString('en-GB')} characters.` }; + if (q.required && !text) return { ok: false, message: `Answer “${q.text}”`, missing: q.text }; + answers.push(text || null); + } + } + const at = this.now().toUTC().toISO()!; + await this.repo.saveQuestionPollAnswers(id, user.userName, answers, at); + const person = (await this.repo.listQuestionPollPeople(id)).find((p) => p.userName === user.userName)!; + await this.redraw(person.cardMessages, view, { kind: 'answered', at: person.answeredAt! }); + return { ok: true, message: `✅ Answered. You can change your answers until ${view.closesTime}.` }; + } + + /** Each tick: close polls whose time has come, send due reminders, and refresh space counts. */ + async tick(): Promise { + const now = this.now(); + for (const poll of await this.repo.listOpenQuestionPolls()) { + try { + if (now >= DateTime.fromISO(poll.closesAt)) await this.close(poll.id, null); + else { + if (poll.remindAt && !poll.remindedAt && now >= DateTime.fromISO(poll.remindAt)) await this.remind(poll); + await this.refreshCount(poll); + } + } catch (err) { + this.log(`poll ${poll.id}: ${err}`); + } + } + } + + /** Closes a poll now; the manager's right to was checked by the caller. */ + async close(id: number, _by: Mention | null): Promise<'closed' | 'already_closed' | 'not_found'> { + const found = await this.get(id); + if (!found) return 'not_found'; + const { standup } = found; + if (!(await this.repo.closeQuestionPoll(id, this.now().toUTC().toISO()!))) return 'already_closed'; + const poll = (await this.repo.getQuestionPoll(id))!; + const view = this.viewOf(poll, standup); + const people = await this.repo.listQuestionPollPeople(id); + for (const p of people) await this.redraw(p.cardMessages, view, { kind: 'closed', answeredAt: p.answeredAt }); + const counts = countsOf(people); + if (poll.spaceMessage) { + try { + await this.adapter.updatePollSummary(poll.spaceMessage, view, counts, true); + } catch (err) { + this.log(`poll ${id} summary redraw failed: ${err}`); + } + } + const results = resultsOf(poll, people, !poll.anonymous); + try { + await this.adapter.postText(standup.spaceName, resultsText(poll, standup, counts, results), `qpoll-${poll.id}`); + } catch (err) { + this.log(`poll ${id} results post failed: ${err}`); + } + await this.webhooks?.pollClosed(standup, { + id: poll.id, + title: poll.title, + anonymous: poll.anonymous, + asked: counts.asked, + answered: counts.answered, + questions: results.map((r) => ({ text: r.text, type: r.type, answered: r.answered, options: r.options })), + }); + return 'closed'; + } + + /** Results for someone allowed to see them; names only when the poll is named and `names` is true. */ + async results(id: number, names: boolean): Promise<{ counts: PollCounts; notAnswered: string[] | null; questions: QuestionResult[] } | null> { + const poll = await this.repo.getQuestionPoll(id); + if (!poll) return null; + const people = await this.repo.listQuestionPollPeople(id); + const withNames = names && !poll.anonymous; + return { + counts: countsOf(people), + notAnswered: withNames ? people.filter((p) => !p.answeredAt).map((p) => p.displayName) : null, + questions: resultsOf(poll, people, withNames), + }; + } + + /** One row per answer; the person is left out of an anonymous poll. */ + async csv(id: number): Promise { + const poll = await this.repo.getQuestionPoll(id); + if (!poll) return null; + const people = await this.repo.listQuestionPollPeople(id); + const header = poll.anonymous ? ['question', 'answer', 'answered_at'] : ['person', 'question', 'answer', 'answered_at']; + const rows = [header]; + for (const p of people) { + if (!p.answers) continue; + for (const [i, q] of poll.questions.entries()) { + const a = p.answers[i]; + if (a === null || a === undefined || (Array.isArray(a) && a.length === 0)) continue; + const text = Array.isArray(a) ? a.map((n) => q.options[n]).join('; ') : a; + rows.push(poll.anonymous ? [q.text, text, p.answeredAt ?? ''] : [p.displayName, q.text, text, p.answeredAt ?? '']); + } + } + return rows.map((r) => r.map(csvCell).join(',')).join('\n') + '\n'; + } + + isClosed(poll: QuestionPoll): boolean { + return poll.closedAt !== null || this.now() >= DateTime.fromISO(poll.closesAt); + } + + /** "Wed 10 Jun, 17:00 Asia/Kolkata": a time as the standup reads it. */ + labelOf(iso: string, standup: Standup): string { + return `${DateTime.fromISO(iso).setZone(standup.timezone).setLocale('en-GB').toFormat('ccc d LLL, HH:mm')} ${standup.timezone}`; + } + + viewOf(poll: QuestionPoll, standup: Standup): PollView { + const closes = DateTime.fromISO(poll.closesAt).setZone(standup.timezone).setLocale('en-GB'); + return { + id: poll.id, + standupId: standup.id, + standupName: standup.name, + title: poll.title, + intro: poll.intro, + from: poll.createdDisplay, + questions: poll.questions, + closesLabel: this.labelOf(poll.closesAt, standup), + closesTime: `${closes.toFormat('HH:mm')} ${standup.timezone}`, + anonymous: poll.anonymous, + results: poll.results, + zone: standup.timezone, + }; + } + + private async remind(poll: QuestionPoll): Promise { + if (!(await this.repo.markQuestionPollReminded(poll.id, this.now().toUTC().toISO()!))) return; + const standup = (await this.repo.getStandupById(poll.standupId))!; + const view = this.viewOf(poll, standup); + for (const p of await this.repo.listQuestionPollPeople(poll.id)) { + if (p.answeredAt || p.remindedAt) continue; + try { + const name = await this.adapter.sendPollCard(p.userName, view, { kind: 'reminder' }); + await this.repo.markQuestionPollPersonReminded(poll.id, p.userName, this.now().toUTC().toISO()!, name); + } catch (err) { + this.log(`poll ${poll.id} reminder to ${p.userName} failed: ${err}`); + } + } + } + + /** Space allows one write a second for every app, so the count redraws at most once a minute. */ + private async refreshCount(poll: QuestionPoll): Promise { + if (!poll.spaceMessage) return; + if (poll.spaceDrawnAt && this.now() < DateTime.fromISO(poll.spaceDrawnAt).plus({ minutes: 1 })) return; + const counts = countsOf(await this.repo.listQuestionPollPeople(poll.id)); + if (counts.answered === poll.spaceCount) return; + const standup = (await this.repo.getStandupById(poll.standupId))!; + await this.adapter.updatePollSummary(poll.spaceMessage, this.viewOf(poll, standup), counts, false); + await this.repo.setQuestionPollSpace(poll.id, poll.spaceMessage, counts.answered, this.now().toUTC().toISO()!); + } + + private async redraw(messageNames: string[], view: PollView, state: PollCardState): Promise { + if (messageNames.length === 0) return; + try { + await this.adapter.updatePollCards(messageNames, view, state); + } catch (err) { + this.log(`poll ${view.id} card redraw failed: ${err}`); + } + } +} + +function countsOf(people: PollPerson[]): PollCounts { + return { asked: people.length, answered: people.filter((p) => p.answeredAt).length }; +} + +function resultsOf(poll: QuestionPoll, people: PollPerson[], names: boolean): QuestionResult[] { + const answered = people.filter((p) => p.answers); + return poll.questions.map((q, i) => { + const given = answered.filter((p) => { + const a = p.answers![i]; + return a !== null && a !== undefined && !(Array.isArray(a) && a.length === 0); + }); + return { + text: q.text, + type: q.type, + answered: given.length, + options: q.options.map((text, n) => { + const who = given.filter((p) => Array.isArray(p.answers![i]) && (p.answers![i] as number[]).includes(n)); + return { text, count: who.length, names: names ? who.map((p) => p.displayName) : null }; + }), + answers: isChoice(q.type) ? [] : given.map((p) => ({ name: names ? p.displayName : null, text: p.answers![i] as string })), + }; + }); +} + +/** The post in the space's thread when a poll closes. */ +export function resultsText(poll: QuestionPoll, standup: Standup, counts: PollCounts, results: QuestionResult[]): string { + const head = `📊 *${poll.title}*`; + if (poll.results === 'managers') return `${head} closed: ${counts.answered} of ${counts.asked} answered. Results are with the standup’s managers.`; + const choice = results.filter((r) => isChoice(r.type)).map((r) => `*${r.text}*\n${r.options.map((o) => `${o.text}: *${o.count}*${o.names?.length ? ` — ${o.names.join(', ')}` : ''}`).join('\n')}`); + const text = results.filter((r) => !isChoice(r.type)).map((r) => `*${r.text}* ${r.answered} answer${r.answered === 1 ? '' : 's'}`); + const parts = [`${head}: ${counts.answered} of ${counts.asked} answered`, ...choice]; + if (text.length) parts.push(`${text.join('\n')}\n\nText answers are in the web app, under ${standup.name} › Polls.`); + return parts.join('\n\n'); +} + +function csvCell(value: string): string { + // A leading =, +, - or @ would run as a formula in a spreadsheet. + const safe = /^[=+\-@]/.test(value) ? `'${value}` : value; + return /[",\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +} diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index 36ffead..934be60 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -82,6 +82,11 @@ export class Scheduler { } catch (err) { this.log(`edit requests: ${err}`); } + try { + await this.service.questionPolls.tick(); + } catch (err) { + this.log(`polls: ${err}`); + } try { await this.providers.leave?.sweep(); } catch (err) { diff --git a/src/core/standup-service.ts b/src/core/standup-service.ts index 04eeafa..8082016 100644 --- a/src/core/standup-service.ts +++ b/src/core/standup-service.ts @@ -3,6 +3,7 @@ import type { ChatAdapter, PromptState } from './adapter.js'; import type { WebhookNotifier } from './webhooks.js'; import type { Repo } from '../db/repo.js'; import { EditRequests } from './edit-requests.js'; +import { QuestionPolls } from './question-polls.js'; import { runProgress } from './progress.js'; import { roundMood } from './insights.js'; import { @@ -33,10 +34,12 @@ export class StandupService { private log: (msg: string) => void = () => {}, ) { this.edits = new EditRequests(repo, adapter, this, now, (msg) => this.log(msg)); + this.questionPolls = new QuestionPolls(repo, adapter, now, (msg) => this.log(msg), webhooks); } /** Asking to edit answers after the deadline. */ readonly edits: EditRequests; + readonly questionPolls: QuestionPolls; /** * Records a dialog submission and posts it under the day's thread. diff --git a/src/core/webhooks.ts b/src/core/webhooks.ts index 70bdd76..ffc5073 100644 --- a/src/core/webhooks.ts +++ b/src/core/webhooks.ts @@ -1,6 +1,18 @@ import { signWebhookBody } from './crypto.js'; +import type { QuestionType } from './poll-types.js'; +import type { OptionResult } from './question-polls.js'; import type { RunSummary, Standup, Submission } from './types.js'; +/** A closed poll's counts; option names are null on an anonymous poll. */ +export interface PollClosed { + id: number; + title: string; + anonymous: boolean; + asked: number; + answered: number; + questions: { text: string; type: QuestionType; answered: number; options: OptionResult[] }[]; +} + export type WebhookEvent = | { event: 'submission'; @@ -36,6 +48,11 @@ export type WebhookEvent = working: boolean; reason: string; requestIds: number[]; + } + | { + event: 'poll_closed'; + standup: { id: number; name: string }; + poll: PollClosed; }; /** @@ -92,6 +109,10 @@ export class WebhookNotifier { }); } + async pollClosed(standup: Standup, poll: PollClosed): Promise { + await this.send(standup, { event: 'poll_closed', standup: { id: standup.id, name: standup.name }, poll }); + } + /** A signed test event, reporting the outcome instead of swallowing it. */ async test(standup: Standup): Promise<{ ok: true; status: number } | { ok: false; error: string }> { const body = JSON.stringify({ event: 'test', standup: { id: standup.id, name: standup.name } }); diff --git a/src/db/repo.ts b/src/db/repo.ts index 4346028..e7abd7f 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -1,4 +1,5 @@ import { PostgresDriver, SqliteDriver, type Driver } from './driver.js'; +import type { PollPerson, QuestionPoll } from '../core/question-polls.js'; import type { Admin, Answer, @@ -468,6 +469,39 @@ CREATE TABLE edit_requests ( ); CREATE INDEX idx_edit_requests_status ON edit_requests(status); ALTER TABLE submissions ADD COLUMN edit_approved_by TEXT; +`, // 21 — polls with questions and a close time + ` +CREATE TABLE question_polls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + standup_id INTEGER NOT NULL REFERENCES standups(id), + title TEXT NOT NULL, + intro TEXT, + questions TEXT NOT NULL, + anonymous INTEGER NOT NULL DEFAULT 0, + results TEXT NOT NULL DEFAULT 'everyone', + closes_at TEXT NOT NULL, + remind_at TEXT, + reminded_at TEXT, + created_by TEXT NOT NULL, + created_display TEXT NOT NULL, + created_at TEXT NOT NULL, + closed_at TEXT, + space_message TEXT, + space_count INTEGER NOT NULL DEFAULT 0, + space_drawn_at TEXT +); +CREATE INDEX idx_question_polls_standup ON question_polls(standup_id); +CREATE TABLE question_poll_people ( + poll_id INTEGER NOT NULL REFERENCES question_polls(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + card_messages TEXT, + reminded_at TEXT, + answers TEXT, + answered_at TEXT, + updated_at TEXT, + PRIMARY KEY (poll_id, user_name) +); `, ]; @@ -813,9 +847,76 @@ CREATE TABLE edit_requests ( ); CREATE INDEX idx_edit_requests_status ON edit_requests(status); ALTER TABLE submissions ADD COLUMN edit_approved_by TEXT; +`, // 21 — polls with questions (mirrors the SQLite migration) + ` +CREATE TABLE question_polls ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + standup_id INTEGER NOT NULL REFERENCES standups(id), + title TEXT NOT NULL, + intro TEXT, + questions TEXT NOT NULL, + anonymous INTEGER NOT NULL DEFAULT 0, + results TEXT NOT NULL DEFAULT 'everyone', + closes_at TEXT NOT NULL, + remind_at TEXT, + reminded_at TEXT, + created_by TEXT NOT NULL, + created_display TEXT NOT NULL, + created_at TEXT NOT NULL, + closed_at TEXT, + space_message TEXT, + space_count INTEGER NOT NULL DEFAULT 0, + space_drawn_at TEXT +); +CREATE INDEX idx_question_polls_standup ON question_polls(standup_id); +CREATE TABLE question_poll_people ( + poll_id INTEGER NOT NULL REFERENCES question_polls(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + card_messages TEXT, + reminded_at TEXT, + answers TEXT, + answered_at TEXT, + updated_at TEXT, + PRIMARY KEY (poll_id, user_name) +); `, ]; +function toQuestionPoll(row: any): QuestionPoll { + return { + id: row.id, + standupId: row.standup_id, + title: row.title, + intro: row.intro ?? null, + questions: JSON.parse(row.questions), + anonymous: !!row.anonymous, + results: row.results, + closesAt: row.closes_at, + remindAt: row.remind_at ?? null, + remindedAt: row.reminded_at ?? null, + createdBy: row.created_by, + createdDisplay: row.created_display, + createdAt: row.created_at, + closedAt: row.closed_at ?? null, + spaceMessage: row.space_message ?? null, + spaceCount: Number(row.space_count ?? 0), + spaceDrawnAt: row.space_drawn_at ?? null, + }; +} + +function toPollPerson(row: any): PollPerson { + return { + pollId: row.poll_id, + userName: row.user_name, + displayName: row.display_name, + cardMessages: row.card_messages ? JSON.parse(row.card_messages) : [], + remindedAt: row.reminded_at ?? null, + answers: row.answers ? JSON.parse(row.answers) : null, + answeredAt: row.answered_at ?? null, + }; +} + function toRole(row: any): CustomRole { return { id: row.id, @@ -2153,6 +2254,77 @@ export class Repo { return (await this.getPollById(id))!; } + // --- polls with questions --- + + async createQuestionPoll( + input: Pick, + people: { userName: string; displayName: string }[], + ): Promise { + const id = await this.db.transaction(async () => { + const id = await this.db.insert( + `INSERT INTO question_polls (standup_id, title, intro, questions, anonymous, results, closes_at, remind_at, created_by, created_display, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [input.standupId, input.title, input.intro, JSON.stringify(input.questions), input.anonymous ? 1 : 0, input.results, input.closesAt, input.remindAt, input.createdBy, input.createdDisplay, input.createdAt], + ); + for (const p of people) { + await this.db.run('INSERT INTO question_poll_people (poll_id, user_name, display_name) VALUES (?, ?, ?)', [id, p.userName, p.displayName]); + } + return id; + }); + return (await this.getQuestionPoll(id))!; + } + + async getQuestionPoll(id: number): Promise { + const row = await this.db.get('SELECT * FROM question_polls WHERE id = ?', [id]); + return row ? toQuestionPoll(row) : null; + } + + /** Newest first. */ + async listQuestionPolls(standupId: number): Promise { + return (await this.db.all('SELECT * FROM question_polls WHERE standup_id = ? ORDER BY id DESC', [standupId])).map(toQuestionPoll); + } + + async listOpenQuestionPolls(): Promise { + return (await this.db.all('SELECT * FROM question_polls WHERE closed_at IS NULL ORDER BY id')).map(toQuestionPoll); + } + + async listQuestionPollPeople(pollId: number): Promise { + return (await this.db.all('SELECT * FROM question_poll_people WHERE poll_id = ? ORDER BY display_name, user_name', [pollId])).map(toPollPerson); + } + + async addQuestionPollCard(pollId: number, userName: string, messageName: string): Promise { + const row = await this.db.get('SELECT card_messages FROM question_poll_people WHERE poll_id = ? AND user_name = ?', [pollId, userName]); + const names: string[] = row?.card_messages ? JSON.parse(row.card_messages) : []; + await this.db.run('UPDATE question_poll_people SET card_messages = ? WHERE poll_id = ? AND user_name = ?', [JSON.stringify([...names, messageName]), pollId, userName]); + } + + async setQuestionPollSpace(id: number, messageName: string, count: number, at: string): Promise { + await this.db.run('UPDATE question_polls SET space_message = ?, space_count = ?, space_drawn_at = ? WHERE id = ?', [messageName, count, at, id]); + } + + /** The first answer's time stays as when they answered; later changes only update. */ + async saveQuestionPollAnswers(pollId: number, userName: string, answers: unknown[], at: string): Promise { + await this.db.run( + 'UPDATE question_poll_people SET answers = ?, answered_at = COALESCE(answered_at, ?), updated_at = ? WHERE poll_id = ? AND user_name = ?', + [JSON.stringify(answers), at, at, pollId, userName], + ); + } + + /** False when the poll was already closed. */ + async closeQuestionPoll(id: number, at: string): Promise { + return (await this.db.run('UPDATE question_polls SET closed_at = ? WHERE id = ? AND closed_at IS NULL', [at, id])).changes > 0; + } + + /** False when the reminder already went. */ + async markQuestionPollReminded(id: number, at: string): Promise { + return (await this.db.run('UPDATE question_polls SET reminded_at = ? WHERE id = ? AND reminded_at IS NULL', [at, id])).changes > 0; + } + + async markQuestionPollPersonReminded(pollId: number, userName: string, at: string, messageName: string | null): Promise { + await this.db.run('UPDATE question_poll_people SET reminded_at = ? WHERE poll_id = ? AND user_name = ?', [at, pollId, userName]); + if (messageName) await this.addQuestionPollCard(pollId, userName, messageName); + } + async getPollById(id: number): Promise { const row = await this.db.get('SELECT * FROM polls WHERE id = ?', [id]); return row ? toPoll(row) : null; @@ -2304,11 +2476,13 @@ export class Repo { const b = await this.db.run(`DELETE FROM blockers WHERE standup_id IN (${standups})`, [tenantId]); await this.db.run(`DELETE FROM poll_votes WHERE poll_id IN (${polls})`, [tenantId]); const p = await this.db.run(`DELETE FROM polls WHERE standup_id IN (${standups})`, [tenantId]); + await this.db.run(`DELETE FROM question_poll_people WHERE poll_id IN (SELECT id FROM question_polls WHERE standup_id IN (${standups}))`, [tenantId]); + const q = await this.db.run(`DELETE FROM question_polls WHERE standup_id IN (${standups})`, [tenantId]); await this.db.run(`DELETE FROM edit_requests WHERE run_id IN (${runs})`, [tenantId]); const sub = await this.db.run(`DELETE FROM submissions WHERE run_id IN (${runs})`, [tenantId]); await this.db.run(`DELETE FROM run_participants WHERE run_id IN (${runs})`, [tenantId]); const r = await this.db.run(`DELETE FROM runs WHERE standup_id IN (${standups})`, [tenantId]); - return { runs: r.changes, submissions: sub.changes, blockers: b.changes, polls: p.changes }; + return { runs: r.changes, submissions: sub.changes, blockers: b.changes, polls: p.changes + q.changes }; }); } @@ -2327,6 +2501,8 @@ export class Repo { const b = await this.db.run('DELETE FROM blockers WHERE standup_id = ?', [standupId]); await this.db.run(`DELETE FROM poll_votes WHERE poll_id IN (${polls})`, [standupId]); const p = await this.db.run('DELETE FROM polls WHERE standup_id = ?', [standupId]); + await this.db.run('DELETE FROM question_poll_people WHERE poll_id IN (SELECT id FROM question_polls WHERE standup_id = ?)', [standupId]); + const q = await this.db.run('DELETE FROM question_polls WHERE standup_id = ?', [standupId]); await this.db.run(`DELETE FROM edit_requests WHERE run_id IN (${runs})`, [standupId]); const sub = await this.db.run(`DELETE FROM submissions WHERE run_id IN (${runs})`, [standupId]); await this.db.run(`DELETE FROM run_participants WHERE run_id IN (${runs})`, [standupId]); @@ -2336,7 +2512,7 @@ export class Repo { await this.db.run('DELETE FROM role_grants WHERE standup_id = ?', [standupId]); await this.db.run('DELETE FROM schedule_change_digests WHERE standup_id = ?', [standupId]); await this.db.run('DELETE FROM standups WHERE id = ?', [standupId]); - return { runs: r.changes, submissions: sub.changes, blockers: b.changes, polls: p.changes }; + return { runs: r.changes, submissions: sub.changes, blockers: b.changes, polls: p.changes + q.changes }; }); } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 68caa32..d0a016d 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -8,6 +8,7 @@ import { actorFor, displayNameOf, managedBy, managesPerson, overrideView, schedu import { allowed, findVisibleStandup, meView, summarise, visibleStandups } from '../api/shared.js'; import { canSomewhere, PERMISSIONS, standupsWith, type Permission } from '../core/permissions.js'; import * as S from '../api/spec/schemas.js'; +import { pollDetailView, pollSummaryView } from '../api/question-polls.js'; import { BLOCKER_STATUSES, blockersView, linkedStandupsView, peopleView, runsView, runView, standupDetailView, visibleBlocker } from '../api/views.js'; import type { ChatAdapter } from '../core/adapter.js'; import type { BlockerService } from '../core/blocker-service.js'; @@ -173,6 +174,34 @@ export function buildMcpServer(deps: McpToolDeps, token: McpToken, p: Principal) }, ); + if (deps.service) { + const polls = deps.service.questionPolls; + register( + 'list_question_polls', + 'read', + { title: 'List polls', description: 'Polls with questions sent to a standup, newest first, with how many have answered.', inputSchema: z.object({ standupId: ID }), outputSchema: S.QuestionPolls }, + async ({ standupId }) => { + const found = await visible(standupId); + return { polls: await Promise.all((await polls.list(standupId)).map((x) => pollSummaryView(polls, x, found.standup))) }; + }, + ); + register( + 'get_question_poll_results', + 'read', + { + title: 'Get poll results', + description: 'One poll with its questions and, when the owner may see them, its results. An anonymous poll never names anyone.', + inputSchema: z.object({ pollId: ID }), + outputSchema: S.QuestionPoll, + }, + async ({ pollId }) => { + const found = await polls.get(pollId); + if (!found || found.standup.tenantId !== p.tenantId || !allowed(p, 'standups.view', found.standup.id)) throw new ToolRefused('No such poll.'); + return pollDetailView(polls, found.poll, found.standup, p); + }, + ); + } + register( 'get_my_standups', 'read', diff --git a/tests/api-question-polls.test.ts b/tests/api-question-polls.test.ts new file mode 100644 index 0000000..56d7d52 --- /dev/null +++ b/tests/api-question-polls.test.ts @@ -0,0 +1,120 @@ +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { EventRouter } from '../src/adapters/gchat/events.js'; +import { newSession, sealSession } from '../src/auth/session.js'; +import { createServer } from '../src/server.js'; +import { makeStack, seedStandup, TENANT } from './helpers.js'; + +const SECRET = 'api-test-secret'; +const OPERATOR = 'dash-secret'; +const CSRF = { 'x-requested-with': 'asyncup', 'content-type': 'application/json' }; +let close: (() => void) | null = null; +afterEach(() => close?.()); + +const DRAFT = { + title: 'Release retro', + questions: [ + { text: 'What went well?', type: 'long', required: true }, + { text: 'Which day?', type: 'single', required: true, options: ['Monday', 'Tuesday'] }, + ], + closesAt: '2026-06-10T17:00', + remindMinutes: 60, +}; + +async function startServer(withService = true) { + const stack = await makeStack(); + const standup = await seedStandup(stack.repo); + await stack.repo.addAdmin(standup.id, 'users/mgr', 'Manager'); + stack.clock.set('2026-06-10T10:00'); + const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT); + const app = createServer({ + router, + scheduler: stack.scheduler, + adapter: stack.adapter, + blockers: stack.blockers, + repo: stack.repo, + settings: stack.settings, + dashboardToken: OPERATOR, + skipVerification: true, + secretKey: SECRET, + ...(withService ? { service: stack.service } : {}), + schedule: stack.schedule, + now: stack.clock.now, + }); + const server = app.listen(0); + close = () => server.close(); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const cookieFor = (sub: string, name = sub) => `asyncup_sess=${sealSession(SECRET, newSession({ sub, email: `${sub}@o.com`, name, admin: false }))}`; + const as = (cookie: string, path: string, init: RequestInit = {}) => fetch(`${url}/api/v1${path}`, { ...init, headers: { cookie, ...CSRF, ...(init.headers as Record) } }); + const op = (path: string, init: RequestInit = {}) => fetch(`${url}/api/v1${path}`, { ...init, headers: { authorization: `Bearer ${OPERATOR}`, 'content-type': 'application/json', ...(init.headers as Record) } }); + const json = (body: unknown) => JSON.stringify(body); + return { ...stack, standup, as, op, json, mgr: cookieFor('mgr', 'Manager'), alice: cookieFor('alice', 'Alice') }; +} + +describe('poll API', () => { + it('lets managers send polls, checks drafts, and lists them for the standup (criteria 1, 19)', async () => { + const { as, json, standup, mgr, alice } = await startServer(); + const path = `/standups/${standup.id}/question-polls`; + expect((await as(alice, path, { method: 'POST', body: json(DRAFT) })).status).toBe(403); + expect(await (await as(mgr, path, { method: 'POST', body: json({ ...DRAFT, title: '' }) })).json()).toMatchObject({ error: { code: 'invalid', field: 'title', message: 'Give the poll a title.' } }); + const res = await as(mgr, path, { method: 'POST', body: json(DRAFT) }); + expect(res.status).toBe(201); + const poll: any = await res.json(); + expect(poll).toMatchObject({ title: 'Release retro', from: 'Manager', closesAt: '2026-06-10T11:30:00.000Z', closesLabel: 'Wed 10 Jun, 17:00 Asia/Kolkata', open: true, anonymous: false, results: 'everyone', asked: 3, answered: 0, remindAt: '2026-06-10T10:30:00.000Z', intro: null }); + expect(poll.questions[0]).toEqual({ text: 'What went well?', type: 'long', required: true, options: [] }); + const list: any = await (await as(alice, path)).json(); + expect(list.polls.map((x: any) => [x.id, x.answered])).toEqual([[poll.id, 0]]); + expect((await as(alice, '/question-polls/999')).status).toBe(404); + }); + + it('shows results to managers, and to the people asked once an everyone-sees poll closes (criterion 16)', async () => { + const { as, json, standup, mgr, alice, service } = await startServer(); + const poll: any = await (await as(mgr, `/standups/${standup.id}/question-polls`, { method: 'POST', body: json(DRAFT) })).json(); + await service.questionPolls.answer(poll.id, { userName: 'users/alice', displayName: 'Alice' }, ['The rollout', [0]]); + expect(((await (await as(alice, `/question-polls/${poll.id}`)).json()) as any).outcome).toBeNull(); + const open: any = await (await as(mgr, `/question-polls/${poll.id}`)).json(); + expect(open.outcome).toEqual({ + notAnswered: ['Bob', 'Carol'], + questions: [ + { text: 'What went well?', type: 'long', answered: 1, options: [], answers: [{ name: 'Alice', text: 'The rollout' }] }, + { text: 'Which day?', type: 'single', answered: 1, options: [{ text: 'Monday', count: 1, names: ['Alice'] }, { text: 'Tuesday', count: 0, names: [] }], answers: [] }, + ], + }); + expect((await as(alice, `/question-polls/${poll.id}/close`, { method: 'POST' })).status).toBe(403); + const closed: any = await (await as(mgr, `/question-polls/${poll.id}/close`, { method: 'POST' })).json(); + expect(closed).toMatchObject({ open: false, closedAt: '2026-06-10T04:30:00.000Z', closedLabel: 'Wed 10 Jun, 10:00 Asia/Kolkata' }); + expect((await as(mgr, `/question-polls/${poll.id}/close`, { method: 'POST' })).status).toBe(409); + expect(((await (await as(alice, `/question-polls/${poll.id}`)).json()) as any).outcome.questions[1].options[0].names).toEqual(['Alice']); + }); + + it('keeps managers-only results from the people asked, and names out of anonymous polls (criteria 16, 17)', async () => { + const { as, json, standup, mgr, alice, service } = await startServer(); + const managers: any = await (await as(mgr, `/standups/${standup.id}/question-polls`, { method: 'POST', body: json({ ...DRAFT, results: 'managers' }) })).json(); + await as(mgr, `/question-polls/${managers.id}/close`, { method: 'POST' }); + expect(((await (await as(alice, `/question-polls/${managers.id}`)).json()) as any).outcome).toBeNull(); + const anon: any = await (await as(mgr, `/standups/${standup.id}/question-polls`, { method: 'POST', body: json({ ...DRAFT, anonymous: true }) })).json(); + await service.questionPolls.answer(anon.id, { userName: 'users/alice', displayName: 'Alice' }, ['The rollout', [1]]); + const seen: any = await (await as(mgr, `/question-polls/${anon.id}`)).json(); + expect(seen.outcome.notAnswered).toBeNull(); + expect(seen.outcome.questions[0].answers).toEqual([{ name: null, text: 'The rollout' }]); + expect(seen.outcome.questions[1].options[1]).toEqual({ text: 'Tuesday', count: 1, names: null }); + }); + + it('exports CSV for managers, without the person on an anonymous poll (criterion 18)', async () => { + const { as, json, standup, mgr, alice, service } = await startServer(); + const named: any = await (await as(mgr, `/standups/${standup.id}/question-polls`, { method: 'POST', body: json(DRAFT) })).json(); + await service.questionPolls.answer(named.id, { userName: 'users/alice', displayName: 'Alice' }, ['=SUM(A1), "quoted"', [1]]); + expect((await as(alice, `/question-polls/${named.id}/export.csv`)).status).toBe(403); + const res = await as(mgr, `/question-polls/${named.id}/export.csv`); + expect(res.headers.get('content-type')).toContain('text/csv'); + expect(await res.text()).toBe(`person,question,answer,answered_at\nAlice,What went well?,"'=SUM(A1), ""quoted""",2026-06-10T04:30:00.000Z\nAlice,Which day?,Tuesday,2026-06-10T04:30:00.000Z\n`); + const anon: any = await (await as(mgr, `/standups/${standup.id}/question-polls`, { method: 'POST', body: json({ ...DRAFT, anonymous: true }) })).json(); + await service.questionPolls.answer(anon.id, { userName: 'users/alice', displayName: 'Alice' }, ['Fine', [0]]); + expect((await (await as(mgr, `/question-polls/${anon.id}/export.csv`)).text()).split('\n')[0]).toBe('question,answer,answered_at'); + }); + + it('answers 503 without the standup service', async () => { + const { op, standup } = await startServer(false); + expect((await op(`/standups/${standup.id}/question-polls`)).status).toBe(503); + }); +}); diff --git a/tests/gchat-adapter.test.ts b/tests/gchat-adapter.test.ts index 1538165..f9c9c23 100644 --- a/tests/gchat-adapter.test.ts +++ b/tests/gchat-adapter.test.ts @@ -256,6 +256,26 @@ describe('GoogleChatAdapter', () => { expect(JSON.stringify(updates[0]!.params.requestBody)).toContain('⌛ Lapsed'); }); + it('DMs poll cards, redraws them, and threads the summary card in the space', async () => { + const { gchat, calls, state } = await makeAdapter(); + const view = { id: 7, standupId: 1, standupName: 'Daily', title: 'Retro', intro: null, from: 'Asha', questions: [], closesLabel: 'Wed 10 Jun, 17:00 Asia/Kolkata', closesTime: '17:00 Asia/Kolkata', anonymous: false, results: 'everyone' as const, zone: 'Asia/Kolkata' }; + expect(await gchat.sendPollCard('users/alice', view, { kind: 'open' })).toBe('spaces/x/messages/1'); + expect(await gchat.postPollSummary(standup, view, { asked: 3, answered: 0 }, false)).toBe('spaces/x/messages/2'); + state.createdName = () => undefined; + expect(await gchat.sendPollCard('users/bob', view, { kind: 'reminder' })).toBeNull(); + await gchat.updatePollCards(['spaces/x/messages/1'], view, { kind: 'closed', answeredAt: null }); + await gchat.updatePollSummary('spaces/x/messages/2', view, { asked: 3, answered: 2 }, true); + const creates = calls.filter((c) => c.method === 'create'); + expect(creates.map((c) => [c.params.parent, c.params.requestBody.cardsV2[0].cardId, c.params.requestBody.thread?.threadKey ?? null])).toEqual([ + ['spaces/dm-alice', 'question-poll-7', null], + ['spaces/team', 'question-poll-summary-7', 'qpoll-7'], + ['spaces/dm-bob', 'question-poll-7', null], + ]); + const updates = calls.filter((c) => c.method === 'update'); + expect(updates.map((c) => c.params.name)).toEqual(['spaces/x/messages/1', 'spaces/x/messages/2']); + expect(JSON.stringify(updates[1]!.params.requestBody)).toContain('Closed: 2 of 3 answered'); + }); + it('posts a poll to the space and returns the message name, or null when absent', async () => { const { gchat, calls, state } = await makeAdapter(); expect(await gchat.postPoll(standup, poll, [1, 0])).toBe('spaces/x/messages/1'); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 93f98b2..0f85d72 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -229,8 +229,8 @@ describe('mcp: tools', () => { const alice = await connect((await mint(cookieFor('alice'), { name: 'a', scopes: FULL })).secret); const MEMBER_TOOLS = [ - 'acknowledge_blocker', 'cancel_day_off', 'get_insights', 'get_me', 'get_my_standups', 'get_run', 'get_schedule', 'get_standup', - 'list_blockers', 'list_runs', 'list_standups', 'resolve_blocker', 'set_days_off', 'set_working_days', + 'acknowledge_blocker', 'cancel_day_off', 'get_insights', 'get_me', 'get_my_standups', 'get_question_poll_results', 'get_run', 'get_schedule', 'get_standup', + 'list_blockers', 'list_question_polls', 'list_runs', 'list_standups', 'resolve_blocker', 'set_days_off', 'set_working_days', 'skip_today', 'submit_answers', 'update_blocker', 'update_my_settings', ]; // Tools the owner's permissions rule out everywhere aren't offered at all. @@ -257,7 +257,7 @@ describe('mcp: tools', () => { ); const root = await connect((await mint(cookieFor('root', true), { name: 'r' })).secret); - expect((await root.listTools()).tools).toHaveLength(11); + expect((await root.listTools()).tools).toHaveLength(13); expect((await call(root, 'get_team')).json.people).toHaveLength(3); }); @@ -459,7 +459,7 @@ describe('mcp: ready for Claude Code', () => { const { mint, cookieFor, connect } = await startServer(); const root = await connect((await mint(cookieFor('root', true), { name: 'r', scopes: FULL })).secret); const tools = (await root.listTools()).tools; - expect(tools).toHaveLength(23); + expect(tools).toHaveLength(25); for (const t of tools) { expect(t.title).toBeTruthy(); expect(t.outputSchema?.type).toBe('object'); @@ -552,3 +552,21 @@ describe('mcp: ready for Claude Code', () => { expect((await call(alice, 'resolve_blocker', { blockerId: id })).error).toBe('No such blocker.'); }); }); + +describe('poll tools', () => { + it('lists a standup’s polls and reads results by permission (criterion 19)', async () => { + const { mint, cookieFor, connect, call, repo, service, clock } = await startServer(); + const s = await seedStandup(repo); + await repo.addAdmin(s.id, BOB.userName, BOB.displayName); + clock.set('2026-06-10T10:00'); + const sent = await service.questionPolls.create(s, { title: 'Retro', intro: null, questions: [{ text: 'Day?', type: 'single', required: true, options: ['Mon', 'Tue'] }], closesAt: '2026-06-10T17:00', remindMinutes: null, anonymous: false, results: 'everyone' }, BOB); + if (!sent.ok) throw new Error(sent.message); + await service.questionPolls.answer(sent.poll.id, ALICE, [[1]]); + const alice = await connect((await mint(cookieFor('alice'), { name: 'a', scopes: ['read'] })).secret); + const bob = await connect((await mint(cookieFor('bob'), { name: 'b', scopes: ['read'] })).secret); + expect((await call(alice, 'list_question_polls', { standupId: s.id })).json.polls.map((p: any) => [p.title, p.answered])).toEqual([['Retro', 1]]); + expect((await call(alice, 'get_question_poll_results', { pollId: sent.poll.id })).json.outcome).toBeNull(); + expect((await call(bob, 'get_question_poll_results', { pollId: sent.poll.id })).json.outcome.questions[0].options[1]).toEqual({ text: 'Tue', count: 1, names: ['Alice'] }); + expect((await call(bob, 'get_question_poll_results', { pollId: 999 })).error).toContain('No such poll.'); + }); +}); diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index c6be965..c7058b7 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -7,8 +7,8 @@ import { POSTGRES_MIGRATIONS, Repo, SQLITE_MIGRATIONS } from '../src/db/repo.js' describe('migration 10 — AI removal', () => { it('keeps both dialect arrays aligned', () => { - expect(SQLITE_MIGRATIONS).toHaveLength(20); - expect(POSTGRES_MIGRATIONS).toHaveLength(20); + expect(SQLITE_MIGRATIONS).toHaveLength(21); + expect(POSTGRES_MIGRATIONS).toHaveLength(21); for (const migrations of [SQLITE_MIGRATIONS, POSTGRES_MIGRATIONS]) { expect(migrations[9]).toContain('DROP COLUMN ai_enabled'); expect(migrations[9]).toContain("'llmApiKey'"); @@ -46,7 +46,7 @@ describe('migration 10 — AI removal', () => { const columns = (check.query('PRAGMA table_info(standups)').all() as { name: string }[]).map((c) => c.name); expect(columns).not.toContain('ai_enabled'); expect(columns).toContain('digest_enabled'); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(20); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(21); check.close(); }, 30_000); }); @@ -90,7 +90,7 @@ describe('migration 11 — MCP tokens and activity', () => { await repo.close(); const check = new Database(dbPath, { readonly: true }); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(20); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(21); check.close(); }); }); @@ -131,7 +131,7 @@ describe('migration 12 — personal schedules', () => { await repo.close(); const check = new Database(dbPath, { readonly: true }); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(20); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(21); check.close(); }); }); diff --git a/tests/question-polls.test.ts b/tests/question-polls.test.ts new file mode 100644 index 0000000..d8e6ec4 --- /dev/null +++ b/tests/question-polls.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'bun:test'; +import { EventRouter } from '../src/adapters/gchat/events.js'; +import { OPEN_POLL_FORM_FN, SUBMIT_POLL_FORM_FN, pollCardMessage, pollFormDialog, pollSummaryMessage } from '../src/adapters/gchat/cards.js'; +import type { PollDraft } from '../src/core/question-polls.js'; +import { makeStack, seedStandup, TENANT } from './helpers.js'; + +const asha = { userName: 'users/admin', displayName: 'Asha' }; +const alice = { userName: 'users/alice', displayName: 'Alice' }; +const bob = { userName: 'users/bob', displayName: 'Bob' }; + +const DRAFT: PollDraft = { + title: 'Release retro', + intro: 'Two minutes, please.', + questions: [ + { text: 'What went well?', type: 'long', required: true, options: [] }, + { text: 'Which day suits the retro?', type: 'single', required: true, options: ['Monday', 'Tuesday', 'Wednesday'] }, + { text: 'What should we change?', type: 'multiple', required: false, options: ['Smaller PRs', 'Earlier freeze'] }, + { text: 'Lunch?', type: 'dropdown', required: false, options: ['Pizza', 'Salad'] }, + { text: 'One word for it', type: 'short', required: false, options: [] }, + ], + closesAt: '2026-06-10T17:00', + remindMinutes: 60, + anonymous: false, + results: 'everyone', +}; + +async function stack(opts: Parameters[0] = {}) { + const s = await makeStack(opts); + const standup = await seedStandup(s.repo); // Alice, Bob, Carol (optional) + await s.repo.addAdmin(standup.id, asha.userName, asha.displayName); + s.clock.set('2026-06-10T10:00'); + const router = new EventRouter(s.commands, s.service, s.blockers, s.repo, TENANT, s.polls, async () => null, s.schedule); + const polls = s.service.questionPolls; + const send = async (draft: Partial = {}) => { + const r = await polls.create(standup, { ...DRAFT, ...draft }, asha); + if (!r.ok) throw new Error(r.message); + return r.poll; + }; + const click = (fn: string, user: typeof alice, params: Record, formInputs: Record = {}) => + router.handle({ + type: 'CARD_CLICKED', + isDialogEvent: fn === SUBMIT_POLL_FORM_FN, + user: { name: user.userName, displayName: user.displayName, type: 'HUMAN' }, + space: { name: 'spaces/dm', type: 'DM' }, + common: { invokedFunction: fn, parameters: params, formInputs: Object.fromEntries(Object.entries(formInputs).map(([k, v]) => [k, { stringInputs: { value: v } }])) }, + }) as Promise; + return { ...s, standup, router, polls, send, click }; +} + +describe('creating a poll', () => { + it('checks the draft before anything is stored (criteria 1–5)', async () => { + const { polls, standup, repo } = await stack(); + const bad = async (draft: Partial) => { + const r = await polls.create(standup, { ...DRAFT, ...draft }, asha); + return r.ok ? 'ok' : `${r.field}: ${r.message}`; + }; + expect(await bad({ title: ' ' })).toBe('title: Give the poll a title.'); + expect(await bad({ title: 'x'.repeat(101) })).toBe('title: Keep the title to 100 characters.'); + expect(await bad({ intro: 'x'.repeat(501) })).toBe('intro: Keep the intro to 500 characters.'); + expect(await bad({ questions: [] })).toBe('questions: Add 1 to 10 questions.'); + expect(await bad({ questions: Array(11).fill(DRAFT.questions[0]) })).toBe('questions: Add 1 to 10 questions.'); + expect(await bad({ questions: [{ text: '', type: 'short', required: true, options: [] }] })).toBe('questions.0: Question 1 needs its text, up to 200 characters.'); + expect(await bad({ questions: [{ text: 'Pick', type: 'single', required: true, options: ['Only one'] }] })).toBe('questions.0: Question 1 needs 2 to 20 options.'); + expect(await bad({ questions: [{ text: 'Pick', type: 'dropdown', required: true, options: ['A', 'a'] }] })).toBe('questions.0: Question 1 has the option “a” twice.'); + expect(await bad({ questions: [{ text: 'Pick', type: 'multiple', required: true, options: ['A', 'x'.repeat(81)] }] })).toBe('questions.0: Keep each option to 80 characters.'); + expect(await bad({ questions: [{ text: 'Pick', type: 'nope' as any, required: true, options: [] }] })).toBe('questions.0: Question 1 has an unknown type.'); + expect(await bad({ closesAt: '2026-06-10T10:10' })).toBe('closesAt: Close it between 15 minutes and 14 days from now.'); + expect(await bad({ closesAt: '2026-06-25T10:00' })).toBe('closesAt: Close it between 15 minutes and 14 days from now.'); + expect(await bad({ closesAt: 'soon' })).toBe('closesAt: Close it between 15 minutes and 14 days from now.'); + expect(await bad({ remindMinutes: 30 })).toBe('remindMinutes: Remind 15 minutes, 1 hour or 1 day before, or not at all.'); + expect(await bad({ closesAt: '2026-06-10T10:30', remindMinutes: 60 })).toBe('remindMinutes: That reminder would be in the past. Choose a shorter one, or none.'); + expect(await bad({ results: 'live' as any })).toBe('results: Results go to everyone after it closes, or to managers only.'); + expect(await polls.list(standup.id)).toEqual([]); + expect(await repo.listQuestionPolls(standup.id)).toEqual([]); + }); + + it('asks everyone active on the roster except people on vacation, by DM, with a summary in the space (criteria 6, 7, 10)', async () => { + const { send, adapter, repo, standup } = await stack(); + await repo.setParticipantVacation(standup.id, 'users/carol', true); + const poll = await send(); + expect(adapter.pollCards.map((c) => [c.userName, c.state.kind])).toEqual([['users/alice', 'open'], ['users/bob', 'open']]); + expect(adapter.pollCards[0]!.view).toMatchObject({ title: 'Release retro', from: 'Asha', closesLabel: 'Wed 10 Jun, 17:00 Asia/Kolkata', questions: DRAFT.questions }); + const summary = adapter.posts.find((p) => p.kind === 'pollSummary')!; + expect(summary).toMatchObject({ spaceName: 'spaces/team', threadKey: `qpoll-${poll.id}`, counts: { asked: 2, answered: 0 }, closed: false }); + expect(await repo.getQuestionPoll(poll.id)).toMatchObject({ closesAt: '2026-06-10T11:30:00.000Z', remindAt: '2026-06-10T10:30:00.000Z' }); + }); + + it('still sends to everyone else when Chat refuses one DM, and logs it', async () => { + const { send, adapter } = await stack(); + const original = adapter.sendPollCard.bind(adapter); + adapter.sendPollCard = async (userName, view, state) => { + if (userName === 'users/alice') throw new Error('no DM space'); + return original(userName, view, state); + }; + await send(); + expect(adapter.pollCards.map((c) => c.userName)).toEqual(['users/bob', 'users/carol']); + }); + + it('refuses a poll nobody on the roster can be asked', async () => { + const { polls, repo, standup } = await stack(); + for (const p of await repo.listParticipants(standup.id)) await repo.setParticipantVacation(standup.id, p.userName, true); + expect(await polls.create(standup, DRAFT, asha)).toEqual({ ok: false, field: 'standupId', message: 'Nobody on Daily Standup can be asked: everyone is on vacation or the roster is empty.' }); + }); +}); + +describe('answering in Chat', () => { + it('opens the form with every question, refuses a missing required answer and keeps what was entered (criterion 8)', async () => { + const { send, click } = await stack(); + const poll = await send(); + const form = await click(OPEN_POLL_FORM_FN, alice, { pollId: String(poll.id) }); + const widgets = form.actionResponse.dialogAction.dialog.body.sections[0].widgets; + expect(widgets.map((w: any) => w.textInput?.label ?? w.selectionInput?.label ?? null).filter(Boolean)).toEqual([ + 'What went well? (required)', + 'Which day suits the retro? (required)', + 'What should we change?', + 'Lunch?', + 'One word for it', + ]); + expect(widgets.find((w: any) => w.selectionInput?.name === 'q1').selectionInput.type).toBe('RADIO_BUTTON'); + expect(widgets.find((w: any) => w.selectionInput?.name === 'q2').selectionInput.type).toBe('CHECK_BOX'); + expect(widgets.find((w: any) => w.selectionInput?.name === 'q3').selectionInput.items[0]).toEqual({ text: 'Choose one', value: '', selected: true }); + expect(widgets.find((w: any) => w.textInput?.name === 'q0').textInput.type).toBe('MULTIPLE_LINE'); + + const missing = await click(SUBMIT_POLL_FORM_FN, alice, { pollId: String(poll.id) }, { q0: ['The rollout'], q2: ['1'] }); + const again = missing.actionResponse.dialogAction.dialog.body.sections[0].widgets; + expect(JSON.stringify(again[0])).toContain('Answer “Which day suits the retro?”'); + expect(again.find((w: any) => w.textInput?.name === 'q0').textInput.value).toBe('The rollout'); + expect(again.find((w: any) => w.selectionInput?.name === 'q2').selectionInput.items.map((i: any) => i.selected)).toEqual([false, true]); + }); + + it('records answers, redraws the person’s card and lets them change answers until the close (criteria 9, 10)', async () => { + const { send, click, adapter, polls, clock } = await stack(); + const poll = await send(); + clock.set('2026-06-10T14:02'); + let reply = await click(SUBMIT_POLL_FORM_FN, alice, { pollId: String(poll.id) }, { q0: ['The rollout'], q1: ['0'], q2: ['0', '1'], q3: ['1'], q4: ['Smooth'] }); + expect(reply.actionResponse.dialogAction.actionStatus).toEqual({ statusCode: 'OK', userFacingMessage: '✅ Answered. You can change your answers until 17:00 Asia/Kolkata.' }); + expect(adapter.pollCardUpdates.at(-1)).toMatchObject({ messageNames: [expect.any(String)], state: { kind: 'answered', at: expect.stringContaining('08:32') } }); + expect((await polls.form(poll.id, alice.userName))).toMatchObject({ ok: true, answers: ['The rollout', [0], [0, 1], [1], 'Smooth'] }); + // Change answers: the form comes back filled in, and the new answers replace the old. + const form = await click(OPEN_POLL_FORM_FN, alice, { pollId: String(poll.id) }); + const widgets = form.actionResponse.dialogAction.dialog.body.sections[0].widgets; + expect(widgets.find((w: any) => w.textInput?.name === 'q0').textInput.value).toBe('The rollout'); + expect(widgets.find((w: any) => w.selectionInput?.name === 'q1').selectionInput.items.map((i: any) => i.selected)).toEqual([true, false, false]); + reply = await click(SUBMIT_POLL_FORM_FN, alice, { pollId: String(poll.id) }, { q0: ['Pairing'], q1: ['2'] }); + expect(reply.actionResponse.dialogAction.actionStatus.statusCode).toBe('OK'); + expect((await polls.form(poll.id, alice.userName))).toMatchObject({ ok: true, answers: ['Pairing', [2], [], null, null] }); + }); + + it('refuses people not asked, bad options and over-long text', async () => { + const { send, click, polls } = await stack(); + const poll = await send(); + expect((await click(OPEN_POLL_FORM_FN, { userName: 'users/zed', displayName: 'Zed' }, { pollId: String(poll.id) })).actionResponse.dialogAction.actionStatus.userFacingMessage).toBe('This poll wasn’t sent to you.'); + expect((await click(OPEN_POLL_FORM_FN, alice, { pollId: '999' })).actionResponse.dialogAction.actionStatus.userFacingMessage).toBe('This poll no longer exists.'); + expect(await polls.answer(poll.id, alice, ['ok', [7], [], null, null])).toEqual({ ok: false, message: 'That option no longer exists.' }); + expect(await polls.answer(poll.id, alice, ['x'.repeat(1001), [0], [], null, null])).toEqual({ ok: false, message: 'Keep “What went well?” to 1,000 characters.' }); + expect(await polls.answer(poll.id, alice, ['ok', [0], [], null, 'x'.repeat(201)])).toEqual({ ok: false, message: 'Keep “One word for it” to 200 characters.' }); + expect(await polls.answer(poll.id, alice, ['ok', [0, 1], [], null, null])).toEqual({ ok: false, message: 'Pick one answer for “Which day suits the retro?”.' }); + }); +}); + +describe('the timer', () => { + it('reminds once, only people who haven’t answered, before the close (criterion 11)', async () => { + const { send, polls, scheduler, clock, adapter } = await stack(); + const poll = await send(); + await polls.answer(poll.id, bob, ['Fine', [1], [], null, null]); + clock.set('2026-06-10T16:00'); + await scheduler.tick(); + await scheduler.tick(); + expect(adapter.pollCards.filter((c) => c.state.kind === 'reminder').map((c) => c.userName)).toEqual(['users/alice', 'users/carol']); + }); + + it('closes at the close time, refuses late answers, redraws every card and posts results (criteria 12, 14, 15)', async () => { + const { send, click, polls, scheduler, clock, adapter, repo } = await stack(); + const poll = await send(); + await polls.answer(poll.id, alice, ['The rollout', [0], [0], null, null]); + await polls.answer(poll.id, bob, ['Pairing', [0], [0, 1], [1], null]); + clock.set('2026-06-10T17:00'); + await scheduler.tick(); + await scheduler.tick(); + expect((await repo.getQuestionPoll(poll.id))!.closedAt).toBe('2026-06-10T11:30:00.000Z'); + const late = await click(SUBMIT_POLL_FORM_FN, alice, { pollId: String(poll.id) }, { q0: ['Late'], q1: ['1'] }); + expect(late.actionResponse.dialogAction.actionStatus.userFacingMessage).toBe('This poll closed at 17:00 Asia/Kolkata.'); + expect(adapter.pollCardUpdates.filter((u) => u.state.kind === 'closed')).toHaveLength(3); + expect(adapter.pollSummaryUpdates.at(-1)).toMatchObject({ counts: { asked: 3, answered: 2 }, closed: true }); + const results = adapter.posts.filter((p) => p.kind === 'text' && p.threadKey === `qpoll-${poll.id}`); + expect(results).toHaveLength(1); + expect(results[0]!.text).toBe( + '📊 *Release retro*: 2 of 3 answered\n\n' + + '*Which day suits the retro?*\nMonday: *2* — Alice, Bob\nTuesday: *0*\nWednesday: *0*\n\n' + + '*What should we change?*\nSmaller PRs: *2* — Alice, Bob\nEarlier freeze: *1* — Bob\n\n' + + '*Lunch?*\nPizza: *0*\nSalad: *1* — Bob\n\n' + + '*What went well?* 2 answers\n*One word for it* 0 answers\n\nText answers are in the web app, under Daily Standup › Polls.', + ); + }); + + it('posts only a count when results are for managers, and never names on an anonymous poll (criteria 15, 17)', async () => { + const { send, polls, adapter } = await stack(); + const managers = await send({ results: 'managers' }); + await polls.answer(managers.id, alice, ['ok', [0], [], null, null]); + expect(await polls.close(managers.id, asha)).toBe('closed'); + expect(adapter.posts.at(-1)!.text).toBe('📊 *Release retro* closed: 1 of 3 answered. Results are with the standup’s managers.'); + const anon = await send({ anonymous: true }); + await polls.answer(anon.id, alice, ['ok', [0], [], null, null]); + await polls.close(anon.id, asha); + expect(adapter.posts.at(-1)!.text).toContain('Monday: *1*\n'); + expect(adapter.posts.at(-1)!.text).not.toContain('Alice'); + }); + + it('closes early once, from a manager (criteria 13, 14)', async () => { + const { send, polls } = await stack(); + const poll = await send(); + expect(await polls.close(poll.id, asha)).toBe('closed'); + expect(await polls.close(poll.id, asha)).toBe('already_closed'); + expect(await polls.close(999, asha)).toBe('not_found'); + }); + + it('redraws the space count at most once a minute (criterion 7)', async () => { + const { send, polls, scheduler, clock, adapter } = await stack(); + const poll = await send(); + await polls.answer(poll.id, alice, ['ok', [0], [], null, null]); + clock.set('2026-06-10T10:00:30'); + await scheduler.tick(); + expect(adapter.pollSummaryUpdates).toHaveLength(0); + clock.set('2026-06-10T10:01:05'); + await scheduler.tick(); + await scheduler.tick(); + expect(adapter.pollSummaryUpdates.map((u) => u.counts)).toEqual([{ asked: 3, answered: 1 }]); + }); + + it('sends a poll_closed webhook, with names only for a named poll (criterion 20)', async () => { + const bodies: any[] = []; + const webhookFetch = (async (_u: string, init: RequestInit) => (bodies.push(JSON.parse(String(init.body))), new Response('ok'))) as unknown as typeof fetch; + const { send, polls, repo, standup } = await stack({ webhookFetch }); + await repo.updateStandup(standup.id, { webhookUrl: 'https://hooks.example.com/x' }); + for (const anonymous of [false, true]) { + const poll = await send({ anonymous }); + await polls.answer(poll.id, alice, ['ok', [0], [], null, null]); + await polls.close(poll.id, asha); + } + const closed = bodies.filter((b) => b.event === 'poll_closed'); + expect(closed.map((b) => [b.poll.anonymous, b.poll.answered, b.poll.questions[1].options[0]])).toEqual([ + [false, 1, { text: 'Monday', count: 1, names: ['Alice'] }], + [true, 1, { text: 'Monday', count: 1, names: null }], + ]); + }); +}); + +describe('the polls command', () => { + it('lists open polls with questions beside quick polls', async () => { + const { send, router, polls } = await stack(); + const inSpace = (text: string) => router.handle({ type: 'MESSAGE', space: { name: 'spaces/team', type: 'ROOM' }, message: { argumentText: text }, user: { name: asha.userName, displayName: asha.displayName, type: 'HUMAN' } }) as Promise; + const poll = await send(); + await polls.answer(poll.id, alice, ['ok', [0], [], null, null]); + expect((await inSpace('polls')).text).toBe('*Polls with questions:*\n📋 Release retro — 1 of 3 answered, closes Wed 10 Jun, 17:00 Asia/Kolkata (by Asha)\nResults are in the web app, under Daily Standup › Polls.'); + await inSpace('poll Deploy? | Yes | No'); + expect((await inSpace('polls')).text).toMatch(/^\*Open polls:\*\n#1 Deploy\? — 0 votes \(by Asha\)\n`poll results` · `poll close`\n\n\*Polls with questions:\*/); + await polls.close(poll.id, asha); + expect((await inSpace('polls')).text).not.toContain('Polls with questions'); + }); +}); + +describe('poll cards', () => { + const view = { id: 4, standupId: 1, standupName: 'Daily Standup', title: 'Release retro', intro: 'Two minutes', from: 'Asha', questions: DRAFT.questions, closesLabel: 'Wed 10 Jun, 17:00 Asia/Kolkata', closesTime: '17:00 Asia/Kolkata', anonymous: false, results: 'everyone' as const, zone: 'Asia/Kolkata' }; + const text = (m: any) => JSON.stringify(m); + + it('draws the DM card for each state, escaping what people typed', () => { + expect(text(pollCardMessage(view, { kind: 'open' }))).toContain('Two <b>minutes</b>'); + expect(text(pollCardMessage(view, { kind: 'open' }))).toContain('5 questions · closes Wed 10 Jun, 17:00 Asia/Kolkata'); + expect(text(pollCardMessage(view, { kind: 'open' }))).toContain('"text":"Respond"'); + expect(text(pollCardMessage(view, { kind: 'reminder' }))).toContain('You haven’t answered yet'); + expect(text(pollCardMessage(view, { kind: 'answered', at: '2026-06-10T08:32:00.000Z' }))).toContain('✅ Answered at 14:02. You can change your answers until 17:00 Asia/Kolkata.'); + expect(text(pollCardMessage(view, { kind: 'answered', at: '2026-06-10T08:32:00.000Z' }))).toContain('"text":"Change answers"'); + const closed = text(pollCardMessage(view, { kind: 'closed', answeredAt: null })); + expect(closed).toContain('🔒 Closed at 17:00 Asia/Kolkata.'); + expect(closed).not.toContain('Respond'); + expect(text(pollCardMessage({ ...view, anonymous: true }, { kind: 'open' }))).toContain('Anonymous: nobody sees who answered what.'); + }); + + it('draws the space summary open and closed', () => { + expect(text(pollSummaryMessage(view, { asked: 8, answered: 3 }, false))).toContain('3 of 8 answered · closes Wed 10 Jun, 17:00 Asia/Kolkata'); + expect(text(pollSummaryMessage({ ...view, results: 'managers' }, { asked: 8, answered: 3 }, false))).toContain('Results go to the standup’s managers.'); + expect(text(pollSummaryMessage(view, { asked: 8, answered: 7 }, true))).toContain('🔒 Closed: 7 of 8 answered. Results are in the thread.'); + }); + + it('builds the form for every question type', () => { + const d = pollFormDialog(view, [null, null, null, null, null]); + const widgets = d.actionResponse.dialogAction.dialog.body.sections[0]!.widgets as any[]; + expect(widgets[0].textParagraph.text).toContain('Two <b>minutes</b>'); + expect(widgets.find((w) => w.textInput?.name === 'q4').textInput.type).toBe('SINGLE_LINE'); + }); +}); diff --git a/web/src/app.tsx b/web/src/app.tsx index 272f2ab..45f4fa8 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -32,6 +32,7 @@ import { StandupLayout } from './pages/standup/layout'; import { OverviewPage } from './pages/standup/overview'; import { InsightsPage } from './pages/standup/insights'; import { StandupSettingsPage } from './pages/standup/settings'; +import { NewPollPage, PollPage, PollsPage } from './pages/standup/polls'; import { StandupsPage } from './pages/standups'; import { Shell } from './shell/shell'; @@ -95,6 +96,9 @@ export function buildRouter(): AnyRouter { createRoute({ getParentRoute: () => standup, path: '/', component: OverviewPage }), createRoute({ getParentRoute: () => standup, path: '/history', component: HistoryPage }), createRoute({ getParentRoute: () => standup, path: '/insights', component: InsightsPage }), + createRoute({ getParentRoute: () => standup, path: '/polls', component: PollsPage }), + createRoute({ getParentRoute: () => standup, path: '/polls/new', component: NewPollPage }), + createRoute({ getParentRoute: () => standup, path: '/polls/$pollId', component: PollPage }), createRoute({ getParentRoute: () => standup, path: '/settings', component: StandupSettingsPage }), ]; const blockers = createRoute({ getParentRoute: () => shell, path: '/blockers', component: BlockersPage }); diff --git a/web/src/lib/polls.ts b/web/src/lib/polls.ts new file mode 100644 index 0000000..1420dcf --- /dev/null +++ b/web/src/lib/polls.ts @@ -0,0 +1,50 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from './api'; + +import type { PollDraft, PollQuestion, QuestionPoll, QuestionPollSummary } from '../../../src/api/spec/schemas.ts'; + +export type { PollDraft, PollQuestion, QuestionPoll, QuestionPollSummary }; +export type QuestionType = PollQuestion['type']; + +export const TYPE_LABEL: Record = { short: 'Short text', long: 'Long text', dropdown: 'Dropdown', single: 'Single choice', multiple: 'Multiple choice' }; +export const REMINDERS: { value: number | null; label: string }[] = [ + { value: 15, label: '15 minutes before it closes' }, + { value: 60, label: '1 hour before it closes' }, + { value: 1440, label: '1 day before it closes' }, + { value: null, label: 'No reminder' }, +]; +export const isChoice = (type: QuestionType) => type !== 'short' && type !== 'long'; + +export function useQuestionPolls(standupId: number) { + return useQuery({ queryKey: ['question-polls', standupId], queryFn: () => api<{ polls: QuestionPollSummary[] }>(`/standups/${standupId}/question-polls`), select: (d) => d.polls }); +} + +export function useQuestionPoll(id: number) { + return useQuery({ queryKey: ['question-poll', id], queryFn: () => api(`/question-polls/${id}`) }); +} + +export function useSendPoll(standupId: number) { + const client = useQueryClient(); + return useMutation({ + mutationFn: (draft: PollDraft) => api(`/standups/${standupId}/question-polls`, { method: 'POST', body: draft }), + onSuccess: () => client.invalidateQueries({ queryKey: ['question-polls', standupId] }), + }); +} + +export function useClosePoll(id: number, standupId: number) { + const client = useQueryClient(); + return useMutation({ + mutationFn: () => api(`/question-polls/${id}/close`, { method: 'POST' }), + onSuccess: (poll) => { + client.setQueryData(['question-poll', id], poll); + return client.invalidateQueries({ queryKey: ['question-polls', standupId] }); + }, + }); +} + +/** "Sent by Asha · 3 of 8 answered · closes Fri 24 Oct, 17:00 Asia/Kolkata" */ +export function pollMeta(p: QuestionPollSummary): string { + return `Sent by ${p.from} · ${p.answered} of ${p.asked} answered · ${p.open ? `closes ${p.closesLabel}` : `closed ${p.closedLabel ?? p.closesLabel}`}`; +} + +export const RESULTS_LABEL: Record = { everyone: 'Everyone, after it closes', managers: 'Managers only' }; diff --git a/web/src/pages/standup/layout.tsx b/web/src/pages/standup/layout.tsx index 34d17d5..38d00d6 100644 --- a/web/src/pages/standup/layout.tsx +++ b/web/src/pages/standup/layout.tsx @@ -16,6 +16,7 @@ const TABS: { to: string; label: string; need: Permission[]; exact?: true }[] = { to: '/standups/$id', label: 'Overview', need: [], exact: true }, { to: '/standups/$id/history', label: 'History', need: ['reports.view'] }, { to: '/standups/$id/insights', label: 'Insights', need: ['reports.view'] }, + { to: '/standups/$id/polls', label: 'Polls', need: [] }, { to: '/standups/$id/settings', label: 'Settings', need: STANDUP_SETTINGS }, ]; @@ -67,7 +68,8 @@ export function StandupLayout() { const href = t.to.replace('$id', String(id)); const current = t.exact ? pathname === `/app${href}` || pathname === `${base}/` : pathname.startsWith(`/app${href}`); return ( - {t.label} + // Overview is the parent route of every tab, so only an exact match makes it the current one. + {t.label} ); })} diff --git a/web/src/pages/standup/polls.test.tsx b/web/src/pages/standup/polls.test.tsx new file mode 100644 index 0000000..9eb1e2e --- /dev/null +++ b/web/src/pages/standup/polls.test.tsx @@ -0,0 +1,166 @@ +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MANAGER, MEMBER, renderApp, stubApi } from '../../test/harness'; + +const ENG = { + id: 1, name: 'Engineering', spaceName: 'spaces/A', active: true, + schedule: { promptTime: '09:30', deadlineTime: '11:30', timezone: 'Asia/Kolkata', days: ['mon', 'tue', 'wed', 'thu', 'fri'], reminderMinutesBefore: 30 }, + questions: ['Q1?'], mood: { enabled: false, anonymous: false }, digestEnabled: false, timeOffPolicy: 'self', + escalation: { afterDays: 2, contact: null }, webhook: { configured: false }, roster: { group: null, syncedAt: null, error: null, leftSpace: [] }, + people: { total: 8, mandatory: 8 }, + today: { date: '2026-10-23', status: null, submitted: 0, expected: 0, missing: [] }, + permissions: { manage: true }, participants: [], admins: [], keptOff: [], +}; +const summary = (over: Record = {}) => ({ + id: 5, standupId: 1, title: 'Release retro', from: 'Asha', createdAt: '2026-10-23T05:30:00.000Z', closesAt: '2026-10-24T11:30:00.000Z', + closesLabel: 'Fri 24 Oct, 17:00 Asia/Kolkata', closedAt: null, closedLabel: null, open: true, anonymous: false, results: 'everyone', asked: 8, answered: 3, ...over, +}); +const POLL = { + ...summary({ open: false, closedAt: '2026-10-24T11:30:00.000Z', closedLabel: 'Fri 24 Oct, 17:00 Asia/Kolkata', answered: 7 }), + intro: null, remindAt: null, + questions: [ + { text: 'What went well?', type: 'long', required: true, options: [] }, + { text: 'Which day suits the retro?', type: 'single', required: true, options: ['Monday', 'Tuesday'] }, + ], + outcome: { + notAnswered: ['Farhan Ali'], + questions: [ + { text: 'What went well?', type: 'long', answered: 2, options: [], answers: [{ name: 'Asha Verma', text: 'The staged rollout' }, { name: 'Bob Mehta', text: 'Pairing' }] }, + { text: 'Which day suits the retro?', type: 'single', answered: 7, options: [{ text: 'Monday', count: 5, names: ['Asha Verma', 'Bob Mehta'] }, { text: 'Tuesday', count: 2, names: [] }], answers: [] }, + ], + }, +}; + +function server(over: Record = {}, me: unknown = MANAGER) { + const bodies: Record[] = []; + const record = (reply: unknown) => (init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body ?? '{}'))); return reply; }; + return { + bodies, + ...stubApi({ + 'GET /api/v1/me': { body: me }, + 'GET /api/v1/standups': { body: { standups: [ENG] } }, + 'GET /api/v1/standups/1': { body: ENG }, + 'GET /api/v1/standups/1/question-polls': { body: { polls: [summary(), summary({ id: 4, title: 'On-call preferences', open: false, closedLabel: 'Fri 17 Oct, 18:00 Asia/Kolkata', anonymous: true, results: 'managers', answered: 6 })] } }, + 'POST /api/v1/standups/1/question-polls': { body: record({ ...POLL, id: 5 }) }, + 'GET /api/v1/question-polls/5': { body: POLL }, + 'POST /api/v1/question-polls/5/close': { body: POLL }, + ...over, + }), + }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('the Polls tab', () => { + it('lists open and closed polls with how many answered, and New poll for managers (criteria 1, 13)', async () => { + server(); + renderApp('/standups/1/polls'); + expect(await screen.findByText('Release retro')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Polls' })).toBeInTheDocument(); + expect(screen.getByText('Sent by Asha · 3 of 8 answered · closes Fri 24 Oct, 17:00 Asia/Kolkata')).toBeInTheDocument(); + expect(screen.getByText('Sent by Asha · 6 of 8 answered · closed Fri 17 Oct, 18:00 Asia/Kolkata')).toBeInTheDocument(); + expect(screen.getByText('Anonymous')).toBeInTheDocument(); + expect(screen.getByText('Managers only')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'New poll' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Results' })).toBeInTheDocument(); + }); + + it('hides New poll from people who can’t send one, and says when nothing is open', async () => { + server({ 'GET /api/v1/standups/1/question-polls': { body: { polls: [] } } }, MEMBER); + renderApp('/standups/1/polls'); + expect(await screen.findByText('No open polls.')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'New poll' })).not.toBeInTheDocument(); + }); +}); + +describe('a new poll', () => { + it('builds questions of each kind, previews them and sends the draft (criteria 1–5)', async () => { + const { bodies, calls } = server(); + renderApp('/standups/1/polls/new'); + const user = userEvent.setup(); + expect(await screen.findByText('Sent by DM to the 8 people on Engineering. People on vacation are skipped.')).toBeInTheDocument(); + await user.type(screen.getByLabelText('Title'), 'Release retro'); + await user.type(screen.getByLabelText('Question 1 text'), 'What went well?'); + await user.selectOptions(screen.getByLabelText('Question 1 type'), 'long'); + await user.click(within(screen.getByRole('group', { name: 'Question 1' })).getByLabelText('Required')); + await user.click(screen.getByRole('button', { name: '+ Add question' })); + await user.type(screen.getByLabelText('Question 2 text'), 'Which day?'); + await user.selectOptions(screen.getByLabelText('Question 2 type'), 'single'); + await user.type(screen.getByLabelText('Question 2 option 1'), 'Monday'); + await user.type(screen.getByLabelText('Question 2 option 2'), 'Tuesday'); + await user.click(screen.getByRole('button', { name: '+ Add option' })); + await user.type(screen.getByLabelText('Question 2 option 3'), 'Friday'); + await user.click(screen.getByRole('button', { name: 'Remove question 2 option 3' })); + await user.click(screen.getByRole('button', { name: 'Move question 2 up' })); + await user.click(screen.getByRole('button', { name: '+ Add question' })); + await user.click(screen.getByRole('button', { name: 'Remove question 3' })); + // The preview follows the builder. + const preview = within(screen.getByRole('region', { name: 'Preview' })); + expect(preview.getByText('Release retro')).toBeInTheDocument(); + expect(preview.getByText('What went well? (required)')).toBeInTheDocument(); + expect(preview.getByText('○ Monday')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Close date'), { target: { value: '2026-10-24' } }); + fireEvent.change(screen.getByLabelText('Close time'), { target: { value: '17:00' } }); + await user.selectOptions(screen.getByLabelText('Reminder'), ''); + await user.click(screen.getByLabelText('Anonymous — nobody sees who answered what')); + await user.selectOptions(screen.getByLabelText('Who sees results'), 'managers'); + await user.click(screen.getByRole('button', { name: 'Send poll' })); + await waitFor(() => expect(bodies).toEqual([{ + title: 'Release retro', + intro: null, + questions: [ + { text: 'Which day?', type: 'single', required: false, options: ['Monday', 'Tuesday'] }, + { text: 'What went well?', type: 'long', required: true, options: [] }, + ], + closesAt: '2026-10-24T17:00', + remindMinutes: null, + anonymous: true, + results: 'managers', + }])); + await waitFor(() => expect(calls).toContain('GET /api/v1/question-polls/5')); + }); + + it('shows why a draft was refused', async () => { + server({ 'POST /api/v1/standups/1/question-polls': { status: 400, body: { error: { code: 'invalid', field: 'title', message: 'Give the poll a title.' } } } }); + renderApp('/standups/1/polls/new'); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Send poll' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Give the poll a title.'); + }); +}); + +describe('poll results', () => { + it('shows who answered, bars per option with names, and every text answer; managers can close early (criteria 13, 16)', async () => { + const { calls } = server({ 'GET /api/v1/question-polls/5': { body: { ...POLL, ...summary(), open: true, outcome: POLL.outcome } } }); + renderApp('/standups/1/polls/5'); + const user = userEvent.setup(); + expect(await screen.findByText('3 of 8 answered')).toBeInTheDocument(); + expect(screen.getByText('Not answered: Farhan Ali')).toBeInTheDocument(); + const day = within(screen.getByRole('region', { name: 'Question 2 results' })); + expect(day.getByText('Monday')).toBeInTheDocument(); + expect(day.getByText('5')).toBeInTheDocument(); + expect(day.getByText('Asha Verma, Bob Mehta')).toBeInTheDocument(); + const well = within(screen.getByRole('region', { name: 'Question 1 results' })); + expect(well.getByText('The staged rollout')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Export this poll as CSV' })).toHaveAttribute('href', '/api/v1/question-polls/5/export.csv'); + await user.click(screen.getByRole('button', { name: 'Close now' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close now' })); + await waitFor(() => expect(calls).toContain('POST /api/v1/question-polls/5/close')); + }); + + it('tells the people asked when results aren’t theirs to see yet', async () => { + server({ 'GET /api/v1/question-polls/5': { body: { ...POLL, results: 'managers', outcome: null } } }, MEMBER); + renderApp('/standups/1/polls/5'); + expect(await screen.findByText('Results are with the standup’s managers.')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Export this poll as CSV' })).not.toBeInTheDocument(); + }); + + it('shows anonymous answers without names, and says results come at the close', async () => { + server({ 'GET /api/v1/question-polls/5': { body: { ...POLL, anonymous: true, outcome: { notAnswered: null, questions: [{ ...POLL.outcome.questions[0], answers: [{ name: null, text: 'Quiet win' }] }, { ...POLL.outcome.questions[1], options: [{ text: 'Monday', count: 0, names: null }] }] } } } }); + renderApp('/standups/1/polls/5'); + expect(await screen.findByText('Quiet win')).toBeInTheDocument(); + expect(screen.queryByText(/Not answered/)).not.toBeInTheDocument(); + expect(screen.getByText(/Anonymous/)).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/standup/polls.tsx b/web/src/pages/standup/polls.tsx new file mode 100644 index 0000000..0e2b516 --- /dev/null +++ b/web/src/pages/standup/polls.tsx @@ -0,0 +1,282 @@ +import { Link, useNavigate, useParams } from '@tanstack/react-router'; +import { useState } from 'react'; +import { ConfirmDialog } from '../../components/confirm-dialog'; +import { useAccess } from '../../lib/access'; +import { ApiError } from '../../lib/api'; +import { isChoice, pollMeta, REMINDERS, RESULTS_LABEL, TYPE_LABEL, useClosePoll, useQuestionPoll, useQuestionPolls, useSendPoll, type PollDraft, type QuestionPollSummary, type QuestionType } from '../../lib/polls'; +import { useStandup } from '../../lib/standups'; +import { useStandupId } from './layout'; + +function Badges({ p }: { p: QuestionPollSummary }) { + return ( + <> + {p.open ? Open : null} + {p.anonymous ? 'Anonymous' : 'Named'} + {RESULTS_LABEL[p.results]} + + ); +} + +function PollRow({ p, standupId }: { p: QuestionPollSummary; standupId: number }) { + return ( +
+
+
{p.title}
+
{pollMeta(p)}
+
+ + {p.open ? 'View' : 'Results'} +
+ ); +} + +/** The standup's Polls tab: open and closed polls, and New poll for its managers. */ +export function PollsPage() { + const id = useStandupId(); + const polls = useQuestionPolls(id); + const access = useAccess(); + if (polls.isError) return
Could not load polls: {polls.error.message}
; + if (!polls.data) return

Loading…

; + const open = polls.data.filter((p) => p.open); + const closed = polls.data.filter((p) => !p.open); + return ( +
+
+
+
+

Polls

+
Ask the team a few questions, with a close time. Only the people on this standup are asked.
+
+ {access.can('standups.configure', id) ? New poll : null} +
+ {open.length ? open.map((p) => ) :

No open polls.

} +
+ {closed.length ? ( +
+

Closed

+ {closed.map((p) => )} +
+ ) : null} +

Quick one-question polls from Chat (poll Question? | A | B) stay in the space and are listed with polls.

+
+ ); +} + +type Draft = { text: string; type: QuestionType; required: boolean; options: string[] }; +const blank = (): Draft => ({ text: '', type: 'short', required: false, options: [] }); +const tomorrow = () => new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + +/** New poll: questions, the close time in the standup's timezone, and who sees what. */ +export function NewPollPage() { + const id = useStandupId(); + const standup = useStandup(id); + const send = useSendPoll(id); + const navigate = useNavigate(); + const [title, setTitle] = useState(''); + const [intro, setIntro] = useState(''); + const [questions, setQuestions] = useState([blank()]); + const [date, setDate] = useState(tomorrow()); + const [time, setTime] = useState('17:00'); + const [remind, setRemind] = useState(60); + const [anonymous, setAnonymous] = useState(false); + const [results, setResults] = useState('everyone'); + const [error, setError] = useState(null); + const zone = standup.data?.schedule.timezone ?? ''; + const asked = standup.data?.people.total ?? 0; + const edit = (i: number, patch: Partial) => setQuestions(questions.map((q, j) => (j === i ? { ...q, ...patch } : q))); + const setType = (i: number, type: QuestionType) => { + const q = questions[i]!; + edit(i, { type, options: isChoice(type) ? (q.options.length >= 2 ? q.options : ['', '']) : [] }); + }; + const move = (i: number, by: number) => { + const qs = [...questions]; + [qs[i], qs[i + by]] = [qs[i + by]!, qs[i]!]; + setQuestions(qs); + }; + const submit = async () => { + setError(null); + try { + const poll = await send.mutateAsync({ + title, + intro: intro.trim() || null, + questions: questions.map((q) => ({ text: q.text, type: q.type, required: q.required, options: isChoice(q.type) ? q.options : [] })), + closesAt: `${date}T${time}`, + remindMinutes: remind as PollDraft["remindMinutes"], + anonymous, + results, + }); + await navigate({ to: '/standups/$id/polls/$pollId', params: { id: String(id), pollId: String(poll.id) } }); + } catch (err) { + setError(err instanceof ApiError || err instanceof Error ? err.message : 'That did not send.'); + } + }; + return ( +
+
+ {error ?
{error}
: null} +
+

New poll

Sent by DM to the {asked} people on {standup.data?.name ?? 'this standup'}. People on vacation are skipped.
+
+
setTitle(e.target.value)} />Up to 100 characters.
+