diff --git a/.factory/oracles/80.json b/.factory/oracles/80.json new file mode 100644 index 0000000..4710907 --- /dev/null +++ b/.factory/oracles/80.json @@ -0,0 +1,22 @@ +{ + "issue": 80, + "files": [ + "worker/durable-object-fake.js", + "worker/polls.test.js", + "worker/polls-zoom.test.js", + "worker/club-poll-template.test.js", + "worker/club-meetings.test.js", + "worker/club-share.test.js", + "worker/routing.test.js", + "worker/csp.test.js", + "worker/gating.test.js", + "packages/shared/__tests__/polls.test.js", + "packages/shared/__tests__/profileMerge.test.js", + "packages/shared/__tests__/reportImage.test.js", + "packages/shared/__tests__/clubArchive.test.js", + "apps/zoom-app/src/utils/zoomSdk.test.js", + "apps/zoom-app/src/components/PollTab.test.jsx", + "apps/zoom-app/src/components/TimerStage.test.jsx", + "e2e/zoom.meeting-polls.spec.js" + ] +} diff --git a/.factory/plans/80-meeting-polls.md b/.factory/plans/80-meeting-polls.md new file mode 100644 index 0000000..d552117 --- /dev/null +++ b/.factory/plans/80-meeting-polls.md @@ -0,0 +1,422 @@ +# Meeting polls: vote for Best Table Topics Speaker, Best Speaker, and Best Evaluator (#80) + +## Problem +Clubs end most meetings by voting for the best Table Topics speaker, best speaker and best +evaluator. Today they count votes by hand or set up a Zoom poll separately, even though the +timer already knows who spoke in each role. #80 asks for a Pro-only ballot built from this +meeting's timed speakers. Running a poll is Pro; voting is free. Votes come in three ways, +shipped together: in the Zoom app after "Invite everyone" (the primary path, review Q3), a web +link `/vote/` (the backup), and a native Zoom poll (host only), whose results are fetched +after the meeting. Club and personal templates hold the ballot setup. Winners show on the +Report tab, the report image and the shared `/r/` page. + +**Review applied (2026-11-15).** Q1 Durable Object. Q2 Zoom app only. Q3 in-Zoom voting with a +verified Zoom identity first, web link as backup with a per-poll-and-IP limiter (~120/min, +fails open). Q4 the dev Marketplace app has every scope, SDK API, webhook and Guest Mode +Testing, so MP-04 and the Zoom-poll and invite steps run for real. Q5 native results are +fetched after the meeting with a short-lived encrypted refresh token. Q6 winners on the +report image and `/r/`. Q7 owner is the creating uid. Q8 zero votes means no winner. + +## Findings +The earlier spec `docs/issues/meeting-polls.md` (38cc6e8) proposed splitting A and B. The +issue overrides that: A and B ship together (decided 2026-11-13). No code exists yet: +`git log -S` finds nothing for poll, vote or ballot, and nothing calls `sendMessageToChat`. + +**Client (Zoom app)** +- **Tabs:** the tab list is `apps/zoom-app/src/components/NavTabs.jsx:8-12`. Tabs are + lazy-loaded and rendered at `App.jsx:13-15,38-40`. Web: `apps/web/src/components/NavTabs.jsx:5-9` + and `pages/TimerApp.jsx:114-118`. +- **Reports:** `toastmaster_reports` (`packages/shared/storage.js:20`). Each entry is + `{speechId, name, role, duration, color, comments, disqualified, finishedAt}` + (`addReport`, zoom `TimerContext.jsx:539-552`). "This meeting" means the reports since the + last Clear, plus the club archive's `meetingId` + (`deriveMeetingId`, `packages/shared/clubArchive.js:218`). +- **Roles:** the built-ins `Table Topics Speech`, `Standard Speech` and `Table Topics + Evaluation` are defined at `packages/shared/timingRules.js:3-12`. `DEFAULT_CUSTOM_RULES` is + at `:17`. `roleOptions` is at zoom `TimerContext.jsx:166-174`. +- **Adding a role:** `addRoleRules(role, rules)` is at `:597-609`. Edit Rules adds a role + through `ensureForked` first (`EditRulesModal.jsx:265-276`, `clubPresets.js:232`). Without + that call, a club device writes the role to personal keys while the club's list stays + active. +- **Pro gating:** `useEntitlement()` returns `isPro` (`hooks/useEntitlement.js:19-28`). + `useSoundsAccess()` (`hooks/useSounds.js:32-39`) is the pattern for + `{usable, lockReason: 'pro'|'unreleased'}`. `UpgradeModal source=…` is at + `components/UpgradeModal.jsx:92`, used as in `SoundsModal.jsx:43-115`. +- **Stage view:** `components/TimerStage.jsx` is a React DOM overlay, mounted from + `LiveTab.jsx:980-1009` and shared with `shareApp`/`appPopout`. A winners panel there is + plain React, with no canvas work. +- **SDK capabilities:** they come from `USED_SDK_APIS` (`utils/zoomSdk.js:78-109`). + `zoomSdk.test.js:1351-1355` checks the list against the `zoomSdk.*` calls in both + directions. +- **SDK state:** `getUserContext` gives `role` (`readZoomUserSummary`, `:2260-2280`). + `getMeetingContext` is not used yet. +- **Settings sync:** the allowlist is `SYNCED_KEYS` (`packages/shared/profileMerge.js:28-43`). + Keys must be written through `persist` (`storage.js:7-16`) or the sync never sees them. +- **Club template precedent:** + - Publish: `publishPresets` → `PUT /api/club/presets` (`clubPresets.js:251`, + `worker/club-presets.js:132-187`). The handler checks session, club, entitled and + admin/editor, writes `club-presets:`, then bumps `club:.ver`. + - Members: `refreshClub` (`packages/shared/club.js:868`, `:906-914`) saves the new + versioned content when `ver` changes. `buildClubState` (`worker/club.js:179-218`) + returns it. + - Lapsed plan: `clubPresetsAvailable()` returns false when the club is not entitled + (`clubPresets.js:71-76`). + - Newest analogue: club cards (`worker/club-cards.js:154-219`, 25536fe). +- **Club archive:** + - Storage: `meeting::…` headers and `speech:::` + keys (`worker/club-meetings.js:19-30`). + - Caveat: `compactMeeting` (`:178-185`) and `readMeeting` (`:393-402`) copy only the fields + they list. A `polls` field in the header would be dropped, so results need their own key + and must be merged in both places. + - Client upload goes through the outbox (`recordSpeech`, `clubArchive.js:281`). + +**Worker** +- **Routing:** API routes are an if-chain at `worker/index.js:136-232`. On the `zoom.` host, + `routeAssets` turns every path into the Zoom app (`:371-377`), so the voting link must + use the `www.` host. +- **Public page precedent:** `/r/` (`index.js:309-311`, `club-share.js:31,48-70,351-391`) + is a Worker-rendered page with an unguessable 16-character token, `noindex` and a real 404. + `ROOT_CSP` (`index.js:21-22`) already allows a same-origin form plus inline script. +- **Storage:** only KV `PROFILES` and R2 `CARD_ASSETS` exist (`wrangler.jsonc:79-95`). There + is no D1 and no Durable Object. + - KV has no compare-and-swap, and `list()` is eventually consistent for about 60 s across + edges. + - So a KV tally made right after Close can miss the last votes, which would make the + revealed winner wrong. +- **Auth:** `readSession` returns `{uid}` (`auth.js:110-122`). Pro is checked with + `resolveAccess(...).entitled`, which returns 402 `upgrade_required` (`profile.js:69-73`). + Release flags are listed in `FLAG_FALLBACKS` (`worker/flags.js:27-45`), and `flags.test.js` + catches drift. +- **Zoom REST:** + - No scopes are set in code; they live in the Marketplace config. + - `exchangeZoomCode` (`auth.js:411-436`) drops the refresh token. No user token is stored. + - The only in-client code flow is PKCE through `zoomSdk.authorize`, in contact capture + (`worker/contact.js:20,143-185`). + - Zoom's `mid` reaches the Worker as a string (`zoom-context.js:133`). Zoom documents + `mid` as the meeting UUID, but `POST /meetings/{meetingId}/polls` takes the numeric + meeting ID, so the client sends `getMeetingContext().meetingID`. +- **Verified identity (Q3):** `decryptAppContext(value, ZOOM_CLIENT_SECRET)` + (`zoom-context.js:95`) returns `{uid, mid, typ, exp}` or null. Guests decrypt fine with + `uid: null`. The client reads the context with `readAppContext()` (`zoomSdk.js:2215`) and + sends it in the body, as `/api/zoom/session` does (`session.js:72`). +- **Webhook (Q5):** `meeting.started` / `meeting.ended` are only sent to PostHog today + (`zoom-webhook.js:142-152`). The payload has `object.id` (numeric) and `object.uuid`. +- **Secrets:** `SESSION_SIGNING_KEY` is deliberately separate from `ZOOM_CLIENT_SECRET` + (`session-token.js:14-16`). Crypto uses `node:crypto` (nodejs_compat). +- **Shared page and image (Q6):** `/r/` renders from the club meeting record + (`club-share.js:237,351`), so polls merged into `readMeeting` reach it. The PNG is drawn + client-side by `packages/shared/reportImage.js` from the reports list. +- **Invite (Q3):** `sendAppInvitationToAllParticipants` is in `@zoom/appssdk` and not used + yet. It carries no payload, so the invited app must find the poll itself. + +**Tests and specs** +- No Zoom-client spec covers `App.jsx`, `NavTabs.jsx`, `ReportTab.jsx`, `TimerStage.jsx`, + `club-presets` or `club-meetings`. +- `zoomSdk.js` is covered by `timer-core` and `contact-capture`. +- `packages/shared/club.js` and `main.jsx` are covered by `club-card-sets`. +- `TimerContext.jsx` is covered by `timer-core` and `agenda-import`. +- Playwright stubs `**/api/zoom/session` for Pro with `page.route` + (`e2e/zoom.timer-sounds.spec.js:7-29`). +- `e2e/zoom.timer-flow.spec.js:125` walks through the tabs, so it changes when a tab is added. + +## Approach +This is one PR, as the issue requires. It is built in the commit order below so a reviewer +can read it in layers. Everything sits behind a new release flag `polls` (default `false`), +and the native Zoom poll has its own flag `polls_zoom`. That flag stays off in production +until Zoom approves the new scopes. + +1. **Shared logic** (`packages/shared/polls.js`, pure functions): + - `defaultBallot()`: Table Topics Speech, Standard Speech and Table Topics Evaluation, + 3 empty slots each. + - `ballotFromTemplate(template, reports, roleNames)`: each category's candidates are the + distinct `name`s from this meeting's reports in its role, in timing order. A category + with none gets `slots` empty entries. + - `normalizeTemplate`, with the keys `categories[{role, label?, slots}]` and + `options{postToChat, showOnStage, expiryHours}`. + - `winners(counts)`: everyone tied at the top count wins, and a category with zero votes + has no winner. + - Storage keys: + - `toastmaster_poll_template`: personal, added to `SYNCED_KEYS`. + - `toastmaster_club_poll_template`: the club copy, saved by `refreshClub` when `ver` + changes. + - `toastmaster_poll_draft`: this meeting's edited ballot, local only. + - `toastmaster_poll_results`: local, cleared with the reports. + - `activePollTemplate()` returns the club template when `clubPresetsAvailable()`, else + the personal one, else the default. A one-meeting edit only writes the draft. +2. **Worker** (`worker/polls.js`, plus a Durable Object `PollRoom` with one instance per + poll): + - Routes: + - `POST /api/polls` (session + Pro) creates the poll. Body: `{ballot, expiryHours, + context}`. The owner's `context` is decrypted and its `mid` is recorded as the poll's + meeting; with no verifiable `mid` the poll still works by link only. It also writes + KV `poll-by-mid:` → id (TTL = expiry) so invited participants can find + it. Returns a 16-character Crockford id (the `/r/` token code); expiry defaults to + 24 h. The ballot holds candidate names and labels only. + - `POST /api/polls/current` (public): body `{context}`. Decrypts it and returns the + open poll's ballot for that `mid`, or 404. This is how an invited app finds the poll. + - `GET /api/polls//ballot` (public). + - `POST /api/polls//votes` (public): body `{choices{catIndex:candIndex}, context?, + device?}`. The voter key and path are decided server-side: + - `context` decrypts with a `uid` and its `mid` matches the poll → key `uid:`, + path `zoom`; + - `context` decrypts with no `uid` (guest) and a matching `mid` → key from `device`, + path `guest`; + - a `context` whose `mid` doesn't match → 403 `wrong_meeting`; an undecryptable + context → 400; + - no `context` (the web link) → key from `device`, path `link`, behind the limiter. + The DO refuses a category this key already voted in (409). Keys are stored hashed. + - `GET /api/polls//results` (owner only): `{counts, byPath{zoom,guest,link}, + status, zoom?}`. + - `POST /api/polls//close` (owner only). It returns `{counts, byPath, winners}` + and deletes the `poll-by-mid` index. + - **Link limiter:** `POLL_VOTE_LIMITER` (`unsafe.bindings` ratelimit, like + `CLUB_ACTIVATE_LIMITER`), key `:`, 120 per 60 s, only on the `link` path. + A limiter error lets the vote through. + - The DO keeps strongly consistent counts, so the totals at Close are final. It deletes + its storage with an alarm at expiry. + - `GET /vote/` on the `www.` host is a Worker-rendered page, like `/r/`. It has + `noindex` and a device token in `localStorage`. Its help text says one vote per device + is not proof against deliberate double voting. + - It shows a closed or expired message and a real 404. +3. **Club template:** + - Route: `PUT /api/club/poll-template`, built on the `requirePublisher` checks from + `club-cards.js`. The handler stores `club-poll-template:` and bumps `ver`. + - Delivery: `buildClubState` returns `pollTemplate`, and `refreshClub` saves it. + - Lapsed plan: the key stays stored but unused, the same as presets. +4. **Archive:** + - Client: Close appends `{pollId, closedAt, categories[{role, label, counts, winners}]}` + to the local results. For a club device it also queues them through the outbox. + - Worker: `POST /api/club/meetings//polls` (club token, like speeches) writes + `poll:::`. `readMeeting` and `compactMeeting` merge it. + - Each stored category keeps `byPath` counts beside the totals. + - **Report image (Q6):** `reportImage.js` draws a "Winners" block (one row per category, + tied names joined) under the speeches when results exist; `reportImageHeight` grows + with it. + - **Shared page (Q6):** `renderSharePage` shows the meeting's poll winners per category + from `meeting.polls`. A category with no votes shows "No votes". +5. **Zoom app UI:** a **POLL** tab after REPORT (`components/PollTab.jsx`). It is hidden + unless `flag('polls')`. On a free plan, setup is locked with `UpgradeModal source="polls"`, + but the voting view is not (Q3). + - **Vote view (any plan, `components/PollVote.jsx`):** on load and when the POLL tab + opens, the app calls `POST /api/polls/current` with its app context. If a poll is open + for this meeting and this device isn't its owner, the app shows a "Vote now" banner and + the POLL tab opens on the ballot. The vote is sent with the context (path `zoom` or + `guest`). After voting: "Thanks, your vote is in", with no counts. + - **Setup:** + - Category rows with a role select of `roleOptions`, an optional label, and + add/remove category. + - Candidates per category: add, remove, rename. + - "New role…" goes through `ensureForked` → `addRoleRules(name, DEFAULT_CUSTOM_RULES)` + → `role_added`, the same as Edit Rules. + - "Save as my template". An admin or editor also gets "Publish to club". + - **Open:** + - Creates the poll (sending the owner's app context) and shows the link with Copy. + - **"Invite everyone"** calls `sendAppInvitationToAllParticipants`. It is the primary + action; a failure shows a toast pointing at the link. + - Posts the link to chat with `sendMessageToChat` when `postToChat` is on. If that + fails it falls back to Copy with a toast. + - Add `sendMessageToChat`, `sendAppInvitationToAllParticipants` and + `getMeetingContext` to `USED_SDK_APIS` as optional. + - **Live:** counts every 5 s while the tab is open (`GET results`), shown only in this + sidebar, with a small "in Zoom / guest / link" split. + - **Close & reveal:** + - Winners per category. Ties show every tied name. + - "Show on Stage" sets a winners panel in `TimerStage.jsx`. It only shows the poll + owner's own Stage. + - The results card appears on ReportTab. +6. **Native Zoom poll (B):** "Create as Zoom poll" shows only when `flag('polls_zoom')` and + `getUserContext().role === 'host'`. + - **Click:** `zoomSdk.authorize` (PKCE, as in contact capture) runs, then + `POST /api/polls/zoom` with `{code, verifier, meetingId}`. + - **Worker:** exchanges the code, then calls `GET /meetings/{id}`. It refuses `type 1` + (instant) with a clear reason, then calls `POST /meetings/{id}/polls` with one + single-choice question per category. The request may also carry `pollId` so the native + poll is linked to the app poll. + - **Results after the meeting (Q5):** + - `exchangeZoomCode` gains an option to return the refresh token (default unchanged: + dropped). + - The refresh token is encrypted with AES-256-GCM under a key derived by HKDF-SHA256 + from `SESSION_SIGNING_KEY` with info `toastmusters/zoom-poll-refresh/v1` (no new + secret; rotating the signing key just orphans pending fetches). It is stored only in + that poll's `PollRoom` DO, with the numeric meeting id and the meeting UUID from + `GET /meetings/{id}`. KV `zoom-poll-by-meeting:` → pollId, TTL 24 h. + - `meeting.ended` in `zoom-webhook.js` looks up that key and tells the DO; the DO sets + an alarm 5 min later (Zoom's processing delay). The alarm refreshes the token, calls + `GET /past_meetings/{uuid}/polls` (UUID double-encoded when it starts with `/` or + contains `//`), stores the results in the poll as `zoom`, and **deletes the token**. + An empty or 404 answer retries at +15 min and +60 min, then gives up and deletes the + token. Every path deletes the token by 24 h after creation at the latest. + - Zoom rotates refresh tokens (the old one stops working once used), so while a + retry is pending the rotated token replaces the stored one, encrypted the same way. + It is still deleted on success, after the last retry, or at 24 h. + - The owner's client reads `zoom` from `GET results` (Report tab, on open) and merges + it into local results; on a club device it queues the archive upload (step 4). + Poll storage for a native poll lives 7 days so the owner can collect the results. + - **Copy:** the UI states Zoom's limits. Only the host can use it. It needs Zoom Pro and + polling turned on, and only works on scheduled meetings. The host launches the poll and + shares results in Zoom's own window; the app shows the results after the meeting. + - **Scopes:** `meeting:write:poll`, `meeting:read:meeting`, + `meeting:read:list_poll_results` (added to the dev app; prod needs a Marketplace review). +7. **Docs:** `docs/CLUBS.md` (template, archive key, events), `docs/FEATURE_FLAGS.md` and + `docs/ZOOM_TEST_PLAN.md` P5 (Pro list plus the new scopes for the reviewer). +8. **Analytics:** `poll_opened`, `poll_invite_sent`, `poll_vote_cast` (Worker, with `path` + and no person data), `poll_closed`, `poll_template_published`, `zoom_poll_created` and + `zoom_poll_results_fetched`. + +**Rejected** +- KV-only votes: a tally right after Close can miss the last votes because KV is eventually + consistent. +- D1: needs a migrations directory and gives nothing a per-poll DO doesn't. +- A web link as the only vote path: one vote per device is easy to game; a verified Zoom + `uid` is the stronger key (Q3). The link stays as the backup. +- A per-IP limit of 30/min: it would refuse a hybrid club voting from one room's Wi-Fi. +- A new secret for the refresh-token key: HKDF from `SESSION_SIGNING_KEY` keeps the keys + separate without a deploy-time secret step. +- Keeping the refresh token after the fetch: it is deleted right after, 24 h at the latest. +- Rendering the vote page in the web SPA: the page would load the whole web bundle on a + phone, while `/r/` already shows the Worker-HTML pattern. +- A `results` field on the meeting header: it would be dropped by compaction. + +## Acceptance criteria +- AC-1: Given a free plan with `polls` on, when the POLL tab opens, then the setup is locked + and "Upgrade to Pro" opens the upgrade modal. The Worker answers `POST /api/polls` with 402. + Voting is never locked: a free-plan participant can vote (AC-14). +- AC-2: Given no template, when a new poll is set up, then it has Table Topics Speech, + Standard Speech and Table Topics Evaluation, 3 slots each. +- AC-3: Categories and candidates can be added and removed. A category's role can be set to + any entry in `roleOptions`, custom roles included. +- AC-4: A role created from the poll screen appears in `roleOptions` with + `DEFAULT_CUSTOM_RULES`. On a club device it goes through `ensureForked`, as in Edit Rules. +- AC-5: Given reports with speakers timed as Table Topics Speech, when the ballot is built, + then those names, deduplicated in timing order, are that category's candidates. A category + with nobody timed shows its empty slots. Candidates can be renamed, added and removed + before opening. +- AC-6: Given an open poll, when someone opens `/vote/` in a browser with no Zoom + sign-in, then they see the ballot and can vote. A second vote from the same device token in + the same category gets 409, and the counts are unchanged. Unknown or expired ids get 404. + A closed poll refuses votes. Link votes are limited per poll and IP to 120 per minute + (429 beyond); if the limiter throws, the vote is accepted. +- AC-7: Results are readable only by the poll's owner; other sessions get 403 and + anonymous callers get 401. The vote page never shows counts. The owner's tab updates counts + while the poll is open. +- AC-8: When the owner closes the poll, then each category shows only its winners, and every + candidate tied at the top count is a winner. "Show on Stage" puts the winners on the + owner's Stage view. +- AC-9: Closed results appear on the Report tab. For a club device they are stored under the + club meeting and returned by `GET /api/club/meetings/`, including after compaction. +- AC-10: An admin or editor publishes the template. A member's device gets it on the next + `refreshClub`. Editing the ballot for one meeting changes neither the club template nor the + member's stored copy. A member gets 403 on publish. On a lapsed club the template stays + stored but is not used. +- AC-11: A Pro user without a club saves a personal template that syncs through + `/api/profile` (`toastmaster_poll_template` in `SYNCED_KEYS`). +- AC-12: Opening a poll posts the voting link to chat with `sendMessageToChat` when + `postToChat` is on, and always shows Copy link. A failed post falls back to Copy with a + toast. `sendMessageToChat` is an optional capability. +- AC-13 (B): "Create as Zoom poll" shows only for the host with `polls_zoom` on, and explains + Zoom's limits. On an instant meeting the Worker refuses, and the app says why. On a + scheduled meeting it creates one single-choice question per category, with the candidates + as answers. The host's refresh token is stored only encrypted (AES-GCM), only in that + poll's DO, never in KV or logs. +- AC-14 (in-Zoom vote): Given an open poll created in meeting M, when a participant in M + opens the app (any plan, including free and guest), then the app finds the poll through + `POST /api/polls/current` and shows the ballot. A signed-in participant's vote is keyed by + their verified `uid`: a second vote in the same category from the same Zoom account, even + on another device, gets 409. A guest's vote is keyed by device token. A context whose `mid` + isn't M gets 403; an undecryptable context gets 400. Each vote records its path + (`zoom`/`guest`/`link`), and the owner's results show counts split by path. +- AC-15 (invite): The owner's "Invite everyone" calls `sendAppInvitationToAllParticipants`; + a refusal or error shows a toast pointing at Copy link. The API is an optional capability + in `USED_SDK_APIS`, as is `getMeetingContext`. +- AC-16 (native results, Q5): Given a native poll created for meeting N, when Zoom sends + `meeting.ended` for N, then 5 min later the DO refreshes the token, fetches + `GET /past_meetings/{uuid}/polls`, stores per-question answer counts as the poll's `zoom` + results and deletes the token. Empty/404 retries at +15 and +60 min, then deletes the + token. With no `meeting.ended`, the token is deleted 24 h after creation. The owner's + Report tab then shows those results and, on a club device, archives them. +- AC-17 (Q6): Given a meeting with closed poll results, the report image draws a Winners + block (ties joined, "No votes" for an empty category) and the shared `/r/` page lists each + category's winners. Without poll results both look exactly as today. + +## Test list +| AC | Unit | Playwright | Zoom-client (NEW spec `meeting-polls`) | +|---|---|---|---| +| AC-1 | `PollTab.test.jsx`, `worker/polls.test.js` (402), `gating.test.js` | `zoom.meeting-polls` (free plan, locked) | MP-01 | +| AC-2 | `packages/shared/__tests__/polls.test.js` | `zoom.meeting-polls` | MP-02 | +| AC-3 | `PollTab.test.jsx` | `zoom.meeting-polls` | MP-03 | +| AC-4 | `PollTab.test.jsx` (ensureForked + addRoleRules) | — | MP-03 | +| AC-5 | `polls.test.js` (`ballotFromTemplate`) | `zoom.meeting-polls` (seeded reports) | MP-02 | +| AC-6 | `worker/polls.test.js` (DO, double vote, expiry, closed), `routing.test.js` (`/vote/`), `csp.test.js` | `zoom.meeting-polls` (vote page against routed API) | MP-05 (human: second device votes) | +| AC-7 | `worker/polls.test.js` (401/403) | — | MP-06 | +| AC-8 | `polls.test.js` (`winners`, ties), `PollTab.test.jsx`, `TimerStage.test.jsx` | `zoom.meeting-polls` | MP-07, MP-08 (Stage on real share) | +| AC-9 | `club-meetings.test.js`, `clubArchive.test.js` | — | MP-09 | +| AC-10 | `worker/club-poll-template.test.js`, `club.test.js` (`pollTemplate` in state), `packages/shared` club refresh test | — | MP-10, MP-11 | +| AC-11 | `profileMerge.test.js` | — | MP-12 | +| AC-12 | `zoomSdk.test.js` (USED_SDK_APIS), `PollTab.test.jsx` | — | MP-04 (human: confirm the chat message, since it sends a message) | +| AC-13 | `worker/polls-zoom.test.js` (fetch mocked; token encrypted, not in KV), `PollTab.test.jsx` (host only) | — | MP-13, MP-14 (Zoom authorization prompt; real, the dev app has the scopes) | +| AC-14 | `worker/polls.test.js` (context paths, uid de-dupe across devices, wrong `mid`, guest), `PollVote.test.jsx` (free plan not locked) | `zoom.meeting-polls` (vote view with a stubbed `current`) | MP-15 (open poll found in the owner's own meeting), MP-16 (human: a second participant votes in Zoom), MP-17 (human: a guest votes; setup has `defaults write ZoomChat enableGuestModeTesting true`) | +| AC-15 | `zoomSdk.test.js` (USED_SDK_APIS), `PollTab.test.jsx` (invite + failure toast) | — | MP-15 (human: confirm the invite reaches the second participant and opens the app) | +| AC-16 | `worker/polls-zoom.test.js` (alarm, retries, token deleted on every path, UUID encoding), `zoom-webhook.test.js` (`meeting.ended` → DO) | — | MP-18 (end the meeting, results appear on Report after the delay) | +| AC-17 | `reportImage.test.js` (winners block, height), `club-share.test.js` (winners on `/r/`) | — | MP-19 (report image and `/r/` show winners) | + +Existing specs to update: +- `e2e/zoom.timer-flow.spec.js:125`: the tab walk. +- `timer-core`: `zoomSdk.js` capabilities, a re-run with no step change. +- `club-card-sets`: `club.js` and `main.jsx` refresh, a re-run with no step change. +- `INDEX.md`: add `meeting-polls` to the table and the run order, after `club-card-sets`. +- Any existing spec whose `covers` lists `zoom-webhook.js`, `reportImage.js`, + `club-share.js` or `ReportTab.jsx`: review its steps for the winners block (re-run only + if no step changes). +- `meeting-polls` setup: the dev app's SDK Status in the Debug Panel (as CC-00) shows + `sendMessageToChat`, `getMeetingContext`, `sendAppInvitationToAllParticipants`, + `authorize`, `onAuthorized` and `promptAuthorize` granted; a scheduled (not instant) test + meeting for MP-13/MP-14/MP-18; the guest device runs + `defaults write ZoomChat enableGuestModeTesting true` and restarts Zoom. + +## Risk +**High.** +- New public, unauthenticated write endpoints (votes, `polls/current`) and a new storage + primitive (a Durable Object binding plus migration in `wrangler.jsonc`, both envs). +- **A stored Zoom credential (Q5):** the host's refresh token is kept for up to 24 h, + encrypted with AES-256-GCM under an HKDF key from `SESSION_SIGNING_KEY`, in that poll's + DO only. While a retry is pending, Zoom's rotated token replaces it (the old one is dead + once used). It is deleted right after the results fetch, after the last retry, or by 24 h, + whichever comes first. It is never logged or returned to a client. +- Votes trust Zoom's encrypted app context for identity and meeting match; a forged or + replayed context fails `decryptAppContext` (expiry is enforced there). +- `meeting.ended` webhook now does work beyond analytics; it still answers within Zoom's 3 s + (the DO call is in `ctx.waitUntil`). +- New Zoom OAuth scopes and SDK APIs, and a Marketplace re-review before `polls_zoom` can be + on in production. +- Changes to the club state payload, archive compaction, the report image and `/r/`. + +riskHigh paths touched: +- `wrangler.jsonc` (the DO binding and migration, plus `POLL_VOTE_LIMITER`). +- `worker/flags.js` (`polls` and `polls_zoom`). +- `worker/auth.js`: `exchangeZoomCode` gains an opt-in to return the refresh token; every + existing caller keeps dropping it. +- `worker/zoom-webhook.js`: `meeting.ended` forwards to the poll DO. + +Mitigations: +- Both flags default off. +- The native poll stays dark in production until Zoom approves the scopes. +- No personal data in the vote URL or the stored votes: uids and device tokens are hashed + in the DO. +- Expiry deletes poll storage; the token has its own shorter deadline. + +Size: the largest factory PR so far, at roughly 40–50 files. The commit order above keeps it +reviewable. + +## Open questions +All answered by ShuhaoZQGG on the PR (2026-11-14); kept here as the record. +- Q1. Vote storage → **Durable Object**, one `PollRoom` per poll. +- Q2. Web app → **Zoom app only** in this PR; shared code and the Worker serve both. +- Q3. Rate limit → **in-Zoom voting with a verified identity is the primary path**; the web + link is the backup, limited per poll and IP to ~120/min, failing open. +- Q4. Marketplace → **done on the dev app** (scopes, SDK APIs, webhooks, Guest Mode Testing). + Run MP-04, MP-13–MP-18 for real; ⏭ only for a setting that is still missing at the run. +- Q5. Native results → **in this PR**, short-lived encrypted refresh token, triggered by + `meeting.ended` after a delay. +- Q6. Report image and `/r/` → **show winners** in this PR. +- Q7. Owner → **the creating uid**. +- Q8. Zero votes → **"No votes", no winner**. diff --git a/apps/zoom-app/src/App.jsx b/apps/zoom-app/src/App.jsx index a0c34ae..d36b935 100644 --- a/apps/zoom-app/src/App.jsx +++ b/apps/zoom-app/src/App.jsx @@ -8,14 +8,18 @@ import TimerSounds from './components/TimerSounds'; import ContactCapture from './components/ContactCapture'; import ZoomConnectionNotice from './components/ZoomConnectionNotice'; import ClubStatusBanner from './components/ClubStatusBanner'; +import { PollVoteBanner } from './components/PollVote'; +import { useFlag } from './hooks/useFlag'; import './App.css'; const LiveTab = lazy(() => import('./components/LiveTab')); const AgendaTab = lazy(() => import('./components/AgendaTab')); const ReportTab = lazy(() => import('./components/ReportTab')); +const PollTab = lazy(() => import('./components/PollTab')); function App() { const [activeTab, setActiveTab] = useState('live'); + const { enabled: pollsOn } = useFlag('polls'); return ( @@ -27,6 +31,8 @@ function App() { is rarely whoever pays. */} + {/* A poll someone opened in this meeting: one tap to the ballot. */} + {pollsOn && activeTab !== 'poll' && setActiveTab('poll')} />} @@ -38,6 +44,7 @@ function App() { {activeTab === 'live' && } {activeTab === 'agenda' && setActiveTab('live')} />} {activeTab === 'report' && } + {activeTab === 'poll' && pollsOn && } diff --git a/apps/zoom-app/src/components/LiveTab.jsx b/apps/zoom-app/src/components/LiveTab.jsx index ae849e2..53675b2 100644 --- a/apps/zoom-app/src/components/LiveTab.jsx +++ b/apps/zoom-app/src/components/LiveTab.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo, memo, lazy, Suspense } from 'react'; +import { useState, useEffect, useRef, useMemo, memo, lazy, Suspense, useSyncExternalStore } from 'react'; import { useTimer, useTimerTick } from '../context/TimerContext'; import { useToast } from '../context/ToastContext'; import { Play, Square, RotateCcw, Eraser, Video, Image, Volume2, Lock } from 'lucide-react'; @@ -14,7 +14,7 @@ import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeIn import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, isClubBadgeMoved, getZoomParticipants, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk'; import { useClub } from '../hooks/useClub'; import { SOUNDS_LOCK_MESSAGES, useSoundsAccess } from '../hooks/useSounds'; -import { formatTime, saveOverlayMode, saveStageClockHidden, loadStageClockHidden, saveRevealFaceWhenIdle, loadRevealFaceWhenIdle } from '@toastmaster-timer/shared'; +import { formatTime, saveOverlayMode, saveStageClockHidden, loadStageClockHidden, saveRevealFaceWhenIdle, loadRevealFaceWhenIdle, getStageWinners, subscribeStageWinners } from '@toastmaster-timer/shared'; import { AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'; import { trackEvent } from '../utils/posthog'; @@ -38,6 +38,8 @@ const PREVIEW_COLORS = [ export default memo(function LiveTab() { const { isRunning, elapsedTime, currentStatus } = useTimerTick(); + // Winners the POLL tab's "Show on Stage" put up, for this device's stage only. + const pollWinners = useSyncExternalStore(subscribeStageWinners, getStageWinners, getStageWinners); const { currentSpeaker, startTimer, @@ -1006,6 +1008,7 @@ export default memo(function LiveTab() { onRenameSpeaker={handleRenameSpeaker} clubKit={clubKit} badgePlacement={badgePlacement} + pollWinners={pollWinners} /> )} diff --git a/apps/zoom-app/src/components/NavTabs.jsx b/apps/zoom-app/src/components/NavTabs.jsx index 7bece08..2b52659 100644 --- a/apps/zoom-app/src/components/NavTabs.jsx +++ b/apps/zoom-app/src/components/NavTabs.jsx @@ -1,5 +1,6 @@ import { memo } from 'react'; import { trackEvent } from '../utils/posthog'; +import { useFlag } from '../hooks/useFlag'; export default memo(function NavTabs({ activeTab, onTabChange }) { // Agenda, Live, Report: the meeting's own order. The agenda is prepared @@ -10,6 +11,9 @@ export default memo(function NavTabs({ activeTab, onTabChange }) { { id: 'live', label: 'LIVE' }, { id: 'report', label: 'REPORT' }, ]; + // The poll comes last, as it does in the meeting: the vote is taken at the end. + const { enabled: pollsOn } = useFlag('polls'); + if (pollsOn) tabs.push({ id: 'poll', label: 'POLL' }); const handleTabChange = (tabId) => { (window.requestIdleCallback || setTimeout)(() => trackEvent('tab_viewed', { diff --git a/apps/zoom-app/src/components/PollTab.jsx b/apps/zoom-app/src/components/PollTab.jsx new file mode 100644 index 0000000..1edca3a --- /dev/null +++ b/apps/zoom-app/src/components/PollTab.jsx @@ -0,0 +1,537 @@ +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import { Lock, Plus, Trash2, X } from 'lucide-react'; +import { + BREAK_ROLE, + DEFAULT_CUSTOM_RULES, + activePollTemplate, + ballotFromTemplate, + canPublishPresets, + categoryTitle, + clubHeaders, + clubPresetsLive, + closedPollResult, + ensureForked, + loadClub, + loadPollDraft, + publishPollTemplate, + recordPollResults, + savePollDraft, + savePollResult, + savePollTemplate, + setStageWinners, +} from '@toastmaster-timer/shared'; +import { useTimer } from '../context/TimerContext'; +import { useToast } from '../context/ToastContext'; +import { useEntitlement } from '../hooks/useEntitlement'; +import { useFlag } from '../hooks/useFlag'; +import { + inviteAllParticipants, + readAppContext, + readMeetingContext, + readZoomUserSummary, + requestZoomAuthorizeCode, + sendChatMessage, +} from '../utils/zoomSdk'; +import { getSessionToken } from '../utils/zoomIdentity'; +import { trackEvent } from '../utils/posthog'; +import ConfirmModal from './ConfirmModal'; +import PollVote from './PollVote'; + +const UpgradeModal = lazy(() => import('./UpgradeModal')); + +/** + * The POLL tab (#80): Best Table Topics Speaker, Best Speaker and Best + * Evaluator, voted on at the end of the meeting. + * + * Setup → open → close, all on this device. The ballot starts from the active + * template (club, personal or default, see packages/shared/polls.js) filled + * with this meeting's timed speakers; an edit for one meeting is kept in the + * poll draft and never touches a template. The Worker holds the votes, so the + * counts at Close are final. Running a poll is Pro; the vote view above the + * setup is for everyone. + */ + +const NEW_ROLE = '__new_role__'; +const POLL_INTERVAL_MS = 5000; + +const authHeaders = () => { + const token = getSessionToken(); + // The club header too: on a club device the club's plan is what makes this Pro. + return { 'Content-Type': 'application/json', ...clubHeaders(), ...(token ? { Authorization: `Bearer ${token}` } : {}) }; +}; + +async function api(method, path, body) { + try { + const response = await fetch(path, { + method, + headers: authHeaders(), + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + return { ok: response.ok, status: response.status, body: await response.json().catch(() => null) }; + } catch { + return { ok: false, status: 0, body: null }; + } +} + +/** What goes to the Worker: names only, blanks dropped. */ +const ballotToSend = (ballot) => ({ + categories: ballot.categories.map((c) => ({ + role: c.role, + ...(c.label?.trim() ? { label: c.label.trim() } : {}), + candidates: c.candidates.map((name) => name.trim()).filter(Boolean), + })), +}); + +const templateFrom = (ballot, options) => ({ + categories: ballot.categories.map((c) => ({ + role: c.role, + ...(c.label?.trim() ? { label: c.label.trim() } : {}), + slots: Math.max(1, c.candidates.length), + })), + options, +}); + +function PathSplit({ byPath, cat }) { + const sum = (path) => (byPath?.[path]?.[cat] ?? []).reduce((a, b) => a + Number(b || 0), 0); + return ( +

+ in Zoom {sum('zoom')} · guest {sum('guest')} · link {sum('link')} +

+ ); +} + +export default function PollTab() { + const { reports, roleOptions, addRoleRules } = useTimer(); + const { showToast } = useToast(); + const { isPro, known } = useEntitlement(); + const { enabled: proReleased } = useFlag('pro'); + const { enabled: zoomPollsOn } = useFlag('polls_zoom'); + + const [template] = useState(() => activePollTemplate()); + const [options, setOptions] = useState(() => template.options); + const [draft, setDraft] = useState( + () => loadPollDraft() ?? ballotFromTemplate(template, reports, roleOptions) + ); + const [newRoleFor, setNewRoleFor] = useState(null); + const [newRoleName, setNewRoleName] = useState(''); + const [showUpgrade, setShowUpgrade] = useState(false); + const [showForkConfirm, setShowForkConfirm] = useState(false); + const forkResolverRef = useRef(null); + const [busy, setBusy] = useState(false); + const [live, setLive] = useState(null); + const [closed, setClosed] = useState(null); + const [isHost, setIsHost] = useState(false); + const [zoomPollNote, setZoomPollNote] = useState(null); + + const poll = draft.poll ?? null; + + useEffect(() => { + let alive = true; + readZoomUserSummary().then((summary) => alive && setIsHost(summary?.role === 'host')); + return () => { + alive = false; + }; + }, []); + + // Counts every five seconds while the poll is open and this tab is showing. + useEffect(() => { + if (!poll?.id) return undefined; + let alive = true; + const read = async () => { + const res = await api('GET', `/api/polls/${poll.id}/results`); + if (alive && res.ok) setLive(res.body); + }; + read(); + const timer = setInterval(read, POLL_INTERVAL_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, [poll?.id]); + + /** Every edit lands in the draft, never in a template. */ + const update = (next) => { + setDraft(next); + savePollDraft(next); + }; + const updateCategory = (i, change) => + update({ ...draft, categories: draft.categories.map((c, k) => (k === i ? { ...c, ...change } : c)) }); + + const candidatesFor = (role) => ballotFromTemplate({ categories: [{ role, slots: 3 }] }, reports, []).categories[0].candidates; + + const setRole = (i, role) => { + const category = draft.categories[i]; + // An untouched category follows its role; one with names typed in keeps them. + const untouched = category.candidates.every((name) => !name.trim()); + updateCategory(i, { role, ...(untouched ? { candidates: candidatesFor(role) } : {}) }); + }; + + const confirmFork = () => + new Promise((resolve) => { + forkResolverRef.current = resolve; + setShowForkConfirm(true); + }); + + const settleFork = (confirmed) => { + setShowForkConfirm(false); + const resolve = forkResolverRef.current; + forkResolverRef.current = null; + resolve?.(confirmed); + }; + + // The same path as Edit Rules: a club device makes its own copy of the club + // list first, or the new role would land in personal keys behind the club's. + const addRole = async (i) => { + const name = newRoleName.trim(); + if (!name) { + showToast('Please enter a name for the new role.', 'error'); + return; + } + if (!roleOptions.includes(name)) { + const wasClubList = clubPresetsLive(); + if (!(await ensureForked(confirmFork))) return; + if (wasClubList) trackEvent('club_presets_forked', { surface: 'zoom', club_id: loadClub()?.club?.id ?? null }); + addRoleRules(name, { ...DEFAULT_CUSTOM_RULES }); + trackEvent('role_added', { role: name, rules: DEFAULT_CUSTOM_RULES, surface: 'polls' }); + } + setNewRoleFor(null); + setNewRoleName(''); + setRole(i, name); + }; + + const saveTemplate = () => { + savePollTemplate(templateFrom(draft, options)); + showToast('Saved as your poll template.', 'success'); + }; + + const publish = async () => { + const result = await publishPollTemplate(templateFrom(draft, options), { getToken: getSessionToken }); + showToast(result.ok ? 'Published to the club.' : 'Could not publish to the club. Try again.', result.ok ? 'success' : 'error'); + }; + + const openPoll = async () => { + setBusy(true); + const ballot = ballotToSend(draft); + const context = await readAppContext(); + const res = await api('POST', '/api/polls', { ballot, expiryHours: options.expiryHours, context }); + setBusy(false); + if (!res.ok || !res.body?.id) { + showToast(res.status === 402 ? 'Running a poll needs Pro.' : 'Could not open the poll. Try again.', 'error'); + return; + } + const opened = { id: res.body.id, url: res.body.url, ballot }; + update({ ...draft, poll: opened }); + setClosed(null); + if (options.postToChat) { + const posted = await sendChatMessage(`Vote for this meeting's best speakers: ${opened.url}`); + if (!posted) showToast('Could not post the link to the chat. Use Copy link to share it.', 'warning', 6000); + } + }; + + const copyLink = async () => { + try { + await navigator.clipboard.writeText(poll.url); + showToast('Link copied.', 'success'); + } catch { + showToast('Select the link and copy it.', 'warning'); + } + }; + + const invite = async () => { + const sent = await inviteAllParticipants().catch(() => false); + if (sent) { + showToast('Invitation sent.', 'success'); + trackEvent('poll_invite_sent', {}); + } else { + showToast('Could not send the invitation. Copy the link and share it instead.', 'warning', 6000); + } + }; + + const createZoomPoll = async () => { + setZoomPollNote(null); + const auth = await requestZoomAuthorizeCode(); + if (auth.status !== 'code') { + setZoomPollNote('Zoom did not approve the request, so no Zoom poll was created.'); + return; + } + const { meetingID } = await readMeetingContext(); + const res = await api('POST', '/api/polls/zoom', { + code: auth.code, + codeVerifier: auth.codeVerifier, + meetingId: meetingID, + pollId: poll.id, + }); + if (res.ok) { + update({ ...draft, poll: { ...poll, zoomPoll: true } }); + setZoomPollNote('Zoom poll created. Launch it from Zoom\'s Polls window; its results show here after the meeting.'); + } else if (/instant/i.test(JSON.stringify(res.body ?? ''))) { + setZoomPollNote('Zoom polls only work on a scheduled meeting, and this is an instant meeting.'); + } else { + setZoomPollNote('Zoom could not create the poll. Check that polling is on for your account.'); + } + }; + + const closePoll = async () => { + setBusy(true); + const res = await api('POST', `/api/polls/${poll.id}/close`); + setBusy(false); + if (!res.ok) { + showToast('Could not close the poll. Try again.', 'error'); + return; + } + const result = { ...closedPollResult(poll.id, poll.ballot, res.body), ...(poll.zoomPoll ? { zoomPoll: true } : {}) }; + savePollResult(result); + recordPollResults(result, { getToken: getSessionToken }); + // The results live on (Report tab, archive); the draft goes back to setup. + const { poll: _done, ...rest } = draft; + update(rest); + setClosed(result); + if (options.showOnStage) setStageWinners(result.categories); + }; + + const newPoll = () => { + setClosed(null); + setLive(null); + }; + + const showOnStage = () => { + setStageWinners(closed.categories); + showToast('The winners are on the Stage. Switch the LIVE tab to Stage to show them.', 'success', 6000); + }; + + const roles = (current) => [...new Set([...roleOptions.filter((r) => r !== BREAK_ROLE), current])]; + + const setup = ( +
+ {draft.categories.map((category, i) => ( +
+ Category {i + 1} +
+ + +
+ {newRoleFor === i && ( +
+ setNewRoleName(e.target.value)} + className="flex-1 min-w-0 rounded border border-gray-300 px-2 py-1 text-sm" + /> + +
+ )} + updateCategory(i, { label: e.target.value })} + className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + /> + {category.candidates.map((name, j) => ( +
+ + updateCategory(i, { candidates: category.candidates.map((n, k) => (k === j ? e.target.value : n)) }) + } + className="flex-1 min-w-0 rounded border border-gray-300 px-2 py-1 text-sm" + /> + +
+ ))} + +
+ ))} + + +
+ + +
+ +
+ + + {canPublishPresets() && ( + + )} +
+
+ ); + + const open = poll && ( +
+ +
+ + + +
+ {poll.ballot.categories.map((category, i) => ( +
+

{categoryTitle(category)}

+
    + {category.candidates.map((name, j) => ( +
  • {name}: {Number(live?.counts?.[i]?.[j] ?? 0)}
  • + ))} +
+ +
+ ))} + {zoomPollsOn && isHost && ( +
+

+ Only the host can create a Zoom poll. It needs Zoom Pro with polling turned on and works only on a + scheduled meeting. You launch it and share its results in Zoom's own Polls window; the results show + here after the meeting. +

+ + {zoomPollNote &&

{zoomPollNote}

} +
+ )} +
+ ); + + const reveal = closed && ( +
+

Winners

+ {closed.categories.map((category, i) => ( +
+

{categoryTitle(category)}

+

+ {category.winners.length ? category.winners.join(', ') : 'No votes'} +

+
+ ))} +
+ + +
+
+ ); + + return ( +
+ + {known && !isPro && ( +
+
+ )} + {isPro && (reveal || open || setup)} + settleFork(true)} + onCancel={() => settleFork(false)} + confirmButtonClass="bg-blue-500 hover:bg-blue-600" + /> + {showUpgrade && ( + + setShowUpgrade(false)} source="polls" /> + + )} +
+ ); +} diff --git a/apps/zoom-app/src/components/PollTab.test.jsx b/apps/zoom-app/src/components/PollTab.test.jsx new file mode 100644 index 0000000..ea8547a --- /dev/null +++ b/apps/zoom-app/src/components/PollTab.test.jsx @@ -0,0 +1,549 @@ +import '@testing-library/jest-dom'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ToastProvider } from '../context/ToastContext'; +import { TimerProvider } from '../context/TimerContext'; +import { trackEvent } from '../utils/posthog'; +import * as zoomSdk from '../utils/zoomSdk'; +import { + CLUB_STORAGE_KEY, + CLUB_PRESETS_STORAGE_KEY, + PRESET_SOURCE_STORAGE_KEY, + DEFAULT_CUSTOM_RULES, + loadRoleRules, + onLocalWrite, + resetClubForTests, + resetEntitlementForTests, + resetFlagsForTests, + setEntitlement, + setFlags, +} from '@toastmaster-timer/shared'; + +/** + * The POLL tab (#80): set up a ballot from this meeting's timed speakers, open + * it, watch it, close it and reveal the winners; and, on any plan, vote in a + * poll someone else opened in this meeting. + * + * The UI contract these tests hold (accessible names, so markup is free): + * - each category is a `group` holding a `Role` select (its options are + * roleOptions plus "New role…"), candidate text boxes named "Candidate …", + * "Add candidate", "Remove candidate" and "Remove category"; "Add category" + * sits below them; + * - "New role…" reveals a "New role name" box and an "Add role" button; + * - "Save as my template", "Publish to club" (admin or editor only), + * "Open poll"; once open: the link, "Copy link", "Invite everyone", + * "Close poll" and (host, polls_zoom) "Create as Zoom poll"; once closed: + * the winners and "Show on Stage"; + * - a free plan sees "Upgrade to Pro" instead of the setup; + * - a poll found through POST /api/polls/current shows its ballot as radio + * buttons named by candidate and a "Submit vote" button. + * + * Zoom is reached only through utils/zoomSdk: readAppContext(), + * readZoomUserSummary() → {status, role}, readMeetingContext() → {meetingID}, + * sendChatMessage(text) and inviteAllParticipants() → true/false (a rejection + * counts as false), requestZoomAuthorizeCode() → {status, code, codeVerifier}. + */ + +// Stubbed rather than imported: the real module pulls in @zoom/appssdk, which +// hangs vitest under jsdom. Anything not named here is a no-op mock. +vi.mock('../utils/zoomSdk', () => { + const named = { + applyOverlay: vi.fn(), + removeOverlay: vi.fn(), + getBackgroundUrl: vi.fn((color) => `/backgrounds/${color}.png`), + isOverlayActive: vi.fn(() => false), + getOverlayMode: vi.fn(() => 'card'), + isVideoOverlayMode: vi.fn(() => false), + setOverlayTimeLabel: vi.fn(), + OVERLAY_MODE_CARD: 'card', + openExternalUrl: vi.fn(async () => true), + isApiAvailable: vi.fn(() => true), + readAppContext: vi.fn(async () => 'ctx-owner'), + readZoomUserSummary: vi.fn(async () => ({ status: 'authorized', role: 'host' })), + readMeetingContext: vi.fn(async () => ({ meetingID: '123456789', meetingTopic: 'Downtown Speakers' })), + sendChatMessage: vi.fn(async () => true), + inviteAllParticipants: vi.fn(async () => true), + requestZoomAuthorizeCode: vi.fn(async () => ({ status: 'code', code: 'auth-code', codeVerifier: 'ver-1' })), + }; + return new Proxy(named, { + get: (target, prop) => { + if (prop in target || typeof prop !== 'string' || prop === 'then' || prop === '__esModule') return target[prop]; + target[prop] = vi.fn(); + return target[prop]; + }, + has: (target, prop) => prop in target || typeof prop === 'string', + }); +}); +vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() })); +vi.mock('../utils/zoomIdentity', () => ({ + getSessionToken: vi.fn(() => 'session-token'), + resolveZoomIdentity: vi.fn(async () => ({ identified: true, uid: 'owner-uid' })), + resetZoomIdentityForTests: vi.fn(), +})); + +// Loaded after the mocks; a variable keeps the bundler from refusing the file +// before the component exists, so each test fails on its own assertion. +const POLL_TAB_MODULE = './PollTab'; +let PollTab = null; +beforeAll(async () => { + PollTab = (await import(/* @vite-ignore */ POLL_TAB_MODULE).catch(() => null))?.default ?? null; +}); + +const PRO = { plan: 'pro', status: 'active', entitled: true, source: 'subscription' }; +const FREE = { plan: 'free', status: null, entitled: false, source: 'none' }; +const POLL_ID = 'ABCDEFGHJKMNPQRS'; +const VOTE_URL = `https://www.timer-dev.simple-tech.app/vote/${POLL_ID}`; +const DEFAULT_ROLES = ['Table Topics Speech', 'Standard Speech', 'Table Topics Evaluation']; + +const report = (name, role, finishedAt) => ({ speechId: `${name}-${finishedAt}`, name, role, duration: '1:30', color: 'green', comments: '', disqualified: false, finishedAt }); +const REPORTS = [ + report('Bob', 'Table Topics Speech', 1_000), + report('Alice', 'Standard Speech', 2_000), + report('Carol', 'Table Topics Speech', 3_000), + report('Bob', 'Table Topics Speech', 4_000), + report('Frank', 'Table Topics Evaluation', 5_000), +]; + +// --------------------------------------------------------------------------- +// The Worker, at the fetch boundary + +let routes; +let calls; + +function json(body, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +function installFetch() { + calls = []; + routes = {}; + global.fetch = vi.fn(async (input, init = {}) => { + const url = typeof input === 'string' ? input : input.url; + const method = (init.method ?? input?.method ?? 'GET').toUpperCase(); + const path = new URL(url, 'https://zoom.timer-dev.simple-tech.app').pathname; + const body = typeof init.body === 'string' ? init.body : null; + calls.push({ method, path, body: body ? JSON.parse(body) : null }); + const handler = routes[`${method} ${path}`]; + return handler ? handler(body ? JSON.parse(body) : null) : json({ error: 'not_found' }, 404); + }); +} + +const callsTo = (method, path) => calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path)); + +// --------------------------------------------------------------------------- + +function joinClub({ role = null, entitled = true } = {}) { + localStorage.setItem( + CLUB_STORAGE_KEY, + JSON.stringify({ + clubToken: 'tok.sig', + lastRefreshAt: Date.now(), + ver: 3, + club: { id: 'club-1', name: 'Downtown Speakers' }, + kit: null, + badge: null, + timezone: null, + role, + plan: entitled ? 'pro' : 'free', + entitled, + status: entitled ? 'active' : 'canceled', + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + source: 'club', + }) + ); + localStorage.setItem( + CLUB_PRESETS_STORAGE_KEY, + JSON.stringify({ rules: { 'Contest Speech': { green: 111, yellow: 222, red: 333, graceAfterRed: 30 } }, order: ['Contest Speech'], hiddenBuiltins: [], publishedBy: 'buyer-uid', publishedAt: 1 }) + ); + resetClubForTests(); +} + +function renderTab() { + expect(PollTab, 'components/PollTab.jsx has a default export').toBeTypeOf('function'); + const user = userEvent.setup(); + render( + + + + + + ); + return user; +} + +/** Every category on screen: a group with a Role select in it. */ +const categories = () => + screen.getAllByRole('group').filter((group) => within(group).queryByRole('combobox', { name: /role/i })); +const roleOf = (group) => within(group).getByRole('combobox', { name: /role/i }); +const candidatesOf = (group) => within(group).queryAllByRole('textbox', { name: /candidate/i }); +const valuesOf = (group) => candidatesOf(group).map((input) => input.value); + +async function setupReady() { + await waitFor(() => expect(categories().length).toBeGreaterThan(0)); +} + +function savePersonalTemplate(options = {}) { + localStorage.setItem( + 'toastmaster_poll_template', + JSON.stringify({ + categories: DEFAULT_ROLES.map((role) => ({ role, slots: 3 })), + options: { postToChat: true, showOnStage: true, expiryHours: 24, ...options }, + }) + ); +} + +function routeOpen() { + routes[`POST /api/polls`] = () => json({ id: POLL_ID, url: VOTE_URL }, 201); + routes[`GET /api/polls/${POLL_ID}/results`] = () => + json({ status: 'open', counts: [[1, 1], [2], [0]], byPath: { zoom: [[1, 0], [1], [0]], guest: [[0, 0], [0], [0]], link: [[0, 1], [1], [0]] } }); + routes[`POST /api/polls/${POLL_ID}/close`] = () => + json({ + counts: [[2, 2], [3], [0]], + byPath: { zoom: [[1, 1], [2], [0]], guest: [[0, 0], [0], [0]], link: [[1, 1], [1], [0]] }, + winners: [['Bob', 'Carol'], ['Alice'], []], + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + resetClubForTests(); + resetEntitlementForTests(); + resetFlagsForTests(); + setFlags({ pro: true, polls: true, polls_zoom: false }); + installFetch(); + zoomSdk.readAppContext.mockResolvedValue('ctx-owner'); + zoomSdk.readZoomUserSummary.mockResolvedValue({ status: 'authorized', role: 'host' }); + zoomSdk.sendChatMessage.mockResolvedValue(true); + zoomSdk.inviteAllParticipants.mockResolvedValue(true); +}); + +// --------------------------------------------------------------------------- + +describe('on the free plan (AC-1)', () => { + it('locks the setup, and "Upgrade to Pro" opens the upgrade modal', async () => { + setEntitlement(FREE); + const user = renderTab(); + + const [upgrade] = await screen.findAllByRole('button', { name: /upgrade to pro/i }); + expect(screen.queryByRole('button', { name: /open poll/i })).not.toBeInTheDocument(); + expect(screen.queryAllByRole('combobox', { name: /role/i })).toHaveLength(0); + + await user.click(upgrade); + await waitFor(() => + expect(trackEvent).toHaveBeenCalledWith('upgrade_prompt_shown', expect.objectContaining({ source: 'polls' })) + ); + }); +}); + +describe('setting up the ballot', () => { + beforeEach(() => setEntitlement(PRO)); + + // AC-2 + it('starts from Table Topics Speech, Standard Speech and Table Topics Evaluation, three empty slots each', async () => { + renderTab(); + await setupReady(); + + expect(categories().map((group) => roleOf(group).value)).toEqual(DEFAULT_ROLES); + for (const group of categories()) expect(valuesOf(group)).toEqual(['', '', '']); + }); + + // AC-5 + it('fills each category with this meeting\'s timed speakers, deduplicated in timing order', async () => { + localStorage.setItem('toastmaster_reports', JSON.stringify(REPORTS)); + renderTab(); + await setupReady(); + + const [topics, speech, evaluation] = categories(); + expect(valuesOf(topics)).toEqual(['Bob', 'Carol']); + expect(valuesOf(speech)).toEqual(['Alice']); + expect(valuesOf(evaluation)).toEqual(['Frank']); + }); + + // AC-3 and AC-5 + it('adds, renames and removes candidates and categories, and offers every role including custom ones', async () => { + localStorage.setItem('toastmaster_reports', JSON.stringify(REPORTS)); + localStorage.setItem('toastmaster_role_rules', JSON.stringify({ 'Contest Speech': { green: 111, yellow: 222, red: 333 } })); + localStorage.setItem('toastmaster_role_order', JSON.stringify(['Contest Speech'])); + const user = renderTab(); + await setupReady(); + + const topics = categories()[0]; + await user.click(within(topics).getByRole('button', { name: /add candidate/i })); + expect(candidatesOf(topics)).toHaveLength(3); + await user.type(candidatesOf(topics)[2], 'Dmitri'); + await user.clear(candidatesOf(topics)[0]); + await user.type(candidatesOf(topics)[0], 'Robert'); + expect(valuesOf(topics)).toEqual(['Robert', 'Carol', 'Dmitri']); + await user.click(within(topics).getAllByRole('button', { name: /remove candidate/i })[1]); + expect(valuesOf(topics)).toEqual(['Robert', 'Dmitri']); + + await user.click(screen.getByRole('button', { name: /add category/i })); + expect(categories()).toHaveLength(4); + const added = categories()[3]; + const options = within(roleOf(added)).getAllByRole('option').map((o) => o.textContent); + expect(options).toEqual(expect.arrayContaining([...DEFAULT_ROLES, 'Ice Breaker', 'Contest Speech'])); + await user.selectOptions(roleOf(added), 'Contest Speech'); + expect(roleOf(categories()[3]).value).toBe('Contest Speech'); + + await user.click(within(categories()[3]).getByRole('button', { name: /remove category/i })); + expect(categories()).toHaveLength(3); + }); + + // AC-4 + it('creates a new role from the poll screen with the default custom rules', async () => { + const user = renderTab(); + await setupReady(); + + const role = roleOf(categories()[0]); + await user.selectOptions(role, within(role).getByRole('option', { name: /new role/i })); + await user.type(await screen.findByRole('textbox', { name: /new role name/i }), 'Humorous Speech Contest'); + await user.click(screen.getByRole('button', { name: /^add role$/i })); + + await waitFor(() => expect(loadRoleRules()?.['Humorous Speech Contest']).toEqual(DEFAULT_CUSTOM_RULES)); + expect(roleOf(categories()[0]).value).toBe('Humorous Speech Contest'); + expect(trackEvent).toHaveBeenCalledWith('role_added', expect.objectContaining({ role: 'Humorous Speech Contest' })); + }); + + it('on a club device, makes the timer\'s own copy of the club list first, as Edit Rules does', async () => { + joinClub(); + const user = renderTab(); + await setupReady(); + + const role = roleOf(categories()[0]); + await user.selectOptions(role, within(role).getByRole('option', { name: /new role/i })); + await user.type(await screen.findByRole('textbox', { name: /new role name/i }), 'Humorous Speech Contest'); + await user.click(screen.getByRole('button', { name: /^add role$/i })); + await user.click(await screen.findByRole('button', { name: /make my copy/i })); + + await waitFor(() => expect(loadRoleRules()?.['Humorous Speech Contest']).toEqual(DEFAULT_CUSTOM_RULES)); + expect(loadRoleRules()['Contest Speech']).toMatchObject({ green: 111, yellow: 222, red: 333 }); + expect(localStorage.getItem(PRESET_SOURCE_STORAGE_KEY)).toBe('personal'); + }); + + // AC-11 + it('saves the ballot setup as a personal template through the synced store', async () => { + const written = []; + const stop = onLocalWrite((key) => written.push(key)); + const user = renderTab(); + await setupReady(); + + await user.click(screen.getByRole('button', { name: /save as my template/i })); + + stop(); + expect(written).toContain('toastmaster_poll_template'); + const saved = JSON.parse(localStorage.getItem('toastmaster_poll_template')); + expect(saved.categories.map((c) => c.role)).toEqual(DEFAULT_ROLES); + }); + + // AC-10 + it('lets an editor publish the setup to the club, and hides that from a member', async () => { + joinClub({ role: 'editor' }); + routes['PUT /api/club/poll-template'] = () => json({ ver: 4, pollTemplate: {} }); + const user = renderTab(); + await setupReady(); + + await user.click(screen.getByRole('button', { name: /publish to club/i })); + await waitFor(() => expect(callsTo('PUT', '/api/club/poll-template')).toHaveLength(1)); + expect(callsTo('PUT', '/api/club/poll-template')[0].body.categories.map((c) => c.role)).toEqual(DEFAULT_ROLES); + }); + + it('shows no publish button to a plain member', async () => { + joinClub({ role: 'member' }); + renderTab(); + await setupReady(); + expect(screen.queryByRole('button', { name: /publish to club/i })).not.toBeInTheDocument(); + }); + + it('starts from the club\'s template on a club device, and an edit for one meeting changes neither template', async () => { + joinClub({ role: 'member' }); + const clubTemplate = JSON.stringify({ + categories: [ + { role: 'Contest Speech', label: 'Best Contestant', slots: 2 }, + { role: 'Table Topics Evaluation', slots: 1 }, + ], + options: { postToChat: true, showOnStage: false, expiryHours: 12 }, + }); + localStorage.setItem('toastmaster_club_poll_template', clubTemplate); + const user = renderTab(); + await setupReady(); + + expect(categories().map((group) => roleOf(group).value)).toEqual(['Contest Speech', 'Table Topics Evaluation']); + expect(categories().map((group) => candidatesOf(group).length)).toEqual([2, 1]); + await user.type(candidatesOf(categories()[0])[0], 'Zed'); + + expect(localStorage.getItem('toastmaster_club_poll_template')).toBe(clubTemplate); + expect(localStorage.getItem('toastmaster_poll_template')).toBeNull(); + }); +}); + +describe('opening the poll', () => { + beforeEach(() => { + setEntitlement(PRO); + localStorage.setItem('toastmaster_reports', JSON.stringify(REPORTS)); + routeOpen(); + }); + + async function openPoll() { + const user = renderTab(); + await setupReady(); + await user.click(screen.getByRole('button', { name: /open poll/i })); + // As text or in a read-only box, either way on screen. + await waitFor(() => expect(screen.queryAllByText(VOTE_URL).length + screen.queryAllByDisplayValue(VOTE_URL).length).toBeGreaterThan(0)); + return user; + } + + // AC-12, AC-14: the owner's context ties the poll to this meeting. + it('creates the poll with this meeting\'s names and context, then shows the link with Copy', async () => { + savePersonalTemplate(); + await openPoll(); + + const [created] = callsTo('POST', '/api/polls'); + expect(created.body.context).toBe('ctx-owner'); + expect(created.body.ballot.categories.map((c) => c.role)).toEqual(DEFAULT_ROLES); + expect(created.body.ballot.categories[0].candidates).toEqual(['Bob', 'Carol']); + expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument(); + }); + + it('posts the link to the meeting chat when postToChat is on', async () => { + savePersonalTemplate({ postToChat: true }); + await openPoll(); + + await waitFor(() => expect(zoomSdk.sendChatMessage).toHaveBeenCalledTimes(1)); + expect(zoomSdk.sendChatMessage.mock.calls[0][0]).toContain(VOTE_URL); + }); + + it('leaves the chat alone when postToChat is off, and still offers Copy link', async () => { + savePersonalTemplate({ postToChat: false }); + await openPoll(); + + expect(zoomSdk.sendChatMessage).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument(); + }); + + it('falls back to Copy link with a toast when the chat post fails', async () => { + savePersonalTemplate({ postToChat: true }); + zoomSdk.sendChatMessage.mockResolvedValue(false); + await openPoll(); + + expect(await screen.findByText((text) => /chat/i.test(text) && /copy/i.test(text))).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument(); + }); + + // AC-15 + it('invites everyone into the app, and points at Copy link when Zoom refuses', async () => { + const user = await openPoll(); + + await user.click(screen.getByRole('button', { name: /invite everyone/i })); + expect(zoomSdk.inviteAllParticipants).toHaveBeenCalledTimes(1); + + zoomSdk.inviteAllParticipants.mockRejectedValueOnce(new Error('refused')); + await user.click(screen.getByRole('button', { name: /invite everyone/i })); + expect(await screen.findByText((text) => /invit/i.test(text) && /link/i.test(text) && /copy/i.test(text))).toBeInTheDocument(); + }); + + // AC-8, AC-9 + it('closes, reveals every winner (ties included, "No votes" for an empty category) and keeps the results', async () => { + const user = await openPoll(); + + await user.click(screen.getByRole('button', { name: /close poll/i })); + + expect(await screen.findByRole('button', { name: /show on stage/i })).toBeInTheDocument(); + expect(callsTo('POST', `/api/polls/${POLL_ID}/close`)).toHaveLength(1); + expect(screen.getAllByText(/Bob/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Carol/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Alice/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/No votes/i).length).toBeGreaterThan(0); + + const stored = JSON.parse(localStorage.getItem('toastmaster_poll_results')); + const closed = stored.find((entry) => entry.pollId === POLL_ID); + expect(closed.categories.map((c) => c.winners)).toEqual([['Bob', 'Carol'], ['Alice'], []]); + }); + + it('queues the closed results for the club archive on a club device', async () => { + joinClub(); + const user = await openPoll(); + + await user.click(screen.getByRole('button', { name: /close poll/i })); + await screen.findByRole('button', { name: /show on stage/i }); + + await waitFor(() => expect(callsTo('POST', /^\/api\/club\/meetings\/[^/]+\/polls$/)).toHaveLength(1)); + expect(callsTo('POST', /^\/api\/club\/meetings\/[^/]+\/polls$/)[0].body).toMatchObject({ pollId: POLL_ID }); + }); + + // AC-13 + describe('as a native Zoom poll', () => { + it('is offered only to the host with polls_zoom on, with Zoom\'s limits spelled out', async () => { + setFlags({ pro: true, polls: true, polls_zoom: true }); + await openPoll(); + + expect(await screen.findByRole('button', { name: /create as zoom poll/i })).toBeInTheDocument(); + expect(screen.getAllByText(/scheduled meeting/i).length).toBeGreaterThan(0); + }); + + it('is not offered to anyone but the host', async () => { + setFlags({ pro: true, polls: true, polls_zoom: true }); + zoomSdk.readZoomUserSummary.mockResolvedValue({ status: 'authorized', role: 'attendee' }); + await openPoll(); + await waitFor(() => expect(zoomSdk.readZoomUserSummary).toHaveBeenCalled()); + expect(screen.queryByRole('button', { name: /create as zoom poll/i })).not.toBeInTheDocument(); + }); + + it('is not offered with polls_zoom off', async () => { + await openPoll(); + expect(screen.queryByRole('button', { name: /create as zoom poll/i })).not.toBeInTheDocument(); + }); + + it('asks Zoom for a code, sends it with the meeting number and the poll, and says why an instant meeting cannot have one', async () => { + setFlags({ pro: true, polls: true, polls_zoom: true }); + routes['POST /api/polls/zoom'] = () => json({ error: 'instant_meeting' }, 409); + const user = await openPoll(); + + await user.click(await screen.findByRole('button', { name: /create as zoom poll/i })); + + await waitFor(() => expect(callsTo('POST', '/api/polls/zoom')).toHaveLength(1)); + expect(zoomSdk.requestZoomAuthorizeCode).toHaveBeenCalled(); + const { body } = callsTo('POST', '/api/polls/zoom')[0]; + expect(body).toMatchObject({ code: 'auth-code', meetingId: '123456789', pollId: POLL_ID }); + expect(body.codeVerifier ?? body.verifier).toBe('ver-1'); + await waitFor(() => expect(screen.getAllByText(/instant meeting/i).length).toBeGreaterThan(0)); + }); + }); +}); + +describe('voting in a poll opened in this meeting (AC-14)', () => { + const VOTE_ID = 'VWXYZ23456789ABC'; + + beforeEach(() => { + zoomSdk.readAppContext.mockResolvedValue('ctx-voter'); + routes['POST /api/polls/current'] = () => + json({ id: VOTE_ID, ballot: { categories: [{ role: 'Standard Speech', label: 'Best Speaker', candidates: ['Alice', 'Bob'] }] } }); + routes[`POST /api/polls/${VOTE_ID}/votes`] = () => json({ ok: true }); + }); + + it('shows the ballot to a free plan, sends the vote with this app\'s context, and thanks them without any counts', async () => { + setEntitlement(FREE); + const user = renderTab(); + + expect((await screen.findAllByText('Best Speaker')).length).toBeGreaterThan(0); + expect(callsTo('POST', '/api/polls/current')[0].body).toMatchObject({ context: 'ctx-voter' }); + + await user.click(screen.getByRole('radio', { name: 'Bob' })); + await user.click(screen.getByRole('button', { name: /submit vote/i })); + + await waitFor(() => expect(callsTo('POST', `/api/polls/${VOTE_ID}/votes`)).toHaveLength(1)); + const { body } = callsTo('POST', `/api/polls/${VOTE_ID}/votes`)[0]; + expect(body.choices).toEqual({ 0: 1 }); + expect(body.context).toBe('ctx-voter'); + expect((await screen.findAllByText(/thanks, your vote is in/i)).length).toBeGreaterThan(0); + expect(screen.queryByText(/\b\d+ votes?\b/i)).not.toBeInTheDocument(); + }); + + it('shows the same ballot to a Pro user who did not open the poll', async () => { + setEntitlement(PRO); + renderTab(); + expect((await screen.findAllByText('Best Speaker')).length).toBeGreaterThan(0); + expect(screen.getByRole('radio', { name: 'Alice' })).toBeInTheDocument(); + }); +}); diff --git a/apps/zoom-app/src/components/PollVote.jsx b/apps/zoom-app/src/components/PollVote.jsx new file mode 100644 index 0000000..cfac1b4 --- /dev/null +++ b/apps/zoom-app/src/components/PollVote.jsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from 'react'; +import { Vote } from 'lucide-react'; +import { categoryTitle, loadPollDraft } from '@toastmaster-timer/shared'; +import { readAppContext } from '../utils/zoomSdk'; + +/** + * Voting in a poll someone opened in this meeting (#80, AC-14). Any plan, + * guests included: running a poll is Pro, voting never is. + * + * "Invite everyone" carries no data, so the invited app finds the poll itself: + * it sends Zoom's encrypted app context and the Worker answers with the open + * poll for that meeting. The vote goes back with the same context, which is + * what keys it to the voter's Zoom account (or, for a guest, to this device). + * Outside Zoom there is no context; the Worker still decides. + */ + +const DEVICE_STORAGE_KEY = 'toastmaster_poll_device'; + +// Polls this device has voted in, so the banner stops asking. Local only: the +// Worker is what refuses a second vote. +const VOTED_STORAGE_KEY = 'toastmaster_poll_voted'; +const voted = { + has: (id) => { + try { + return JSON.parse(localStorage.getItem(VOTED_STORAGE_KEY) ?? '[]').includes(id); + } catch { + return false; + } + }, + add: (id) => { + try { + // The last few only: a poll lives a day at most. + const ids = JSON.parse(localStorage.getItem(VOTED_STORAGE_KEY) ?? '[]').filter((v) => v !== id); + localStorage.setItem(VOTED_STORAGE_KEY, JSON.stringify([...ids, id].slice(-20))); + } catch { + // Private mode: the banner may ask again; the Worker still refuses a repeat. + } + }, +}; + +/** A random token for this device, kept so one device is one guest vote. */ +function deviceToken() { + try { + let token = localStorage.getItem(DEVICE_STORAGE_KEY); + if (!token) { + token = globalThis.crypto?.randomUUID?.() ?? `d-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + localStorage.setItem(DEVICE_STORAGE_KEY, token); + } + return token; + } catch { + return null; + } +} + +/** + * The poll open in this meeting that this device did not open itself, or null. + * + * @returns {Promise<{id: string, ballot: Object, context: string|null}|null>} + */ +export async function findCurrentPoll() { + const context = await readAppContext(); + try { + const response = await fetch('/api/polls/current', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ context }), + }); + if (!response.ok) return null; + const body = await response.json(); + if (!body?.id || !Array.isArray(body.ballot?.categories)) return null; + if (body.id === loadPollDraft()?.poll?.id) return null; + return { id: body.id, ballot: body.ballot, context }; + } catch { + return null; + } +} + +/** "Vote now", above the tabs, while a poll is open and this device has not voted. */ +export function PollVoteBanner({ onOpen }) { + const [poll, setPoll] = useState(null); + + useEffect(() => { + let live = true; + findCurrentPoll().then((found) => live && setPoll(found)); + return () => { + live = false; + }; + }, []); + + if (!poll || voted.has(poll.id)) return null; + return ( + + ); +} + +const ERRORS = { + 409: 'You already voted in this poll.', + 403: 'This poll belongs to another meeting.', + 404: 'This poll has closed.', + 410: 'This poll has closed.', +}; + +export default function PollVote() { + const [poll, setPoll] = useState(null); + const [choices, setChoices] = useState({}); + const [phase, setPhase] = useState('voting'); // voting | sending | done + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + findCurrentPoll().then((found) => { + if (!live || !found) return; + setPoll(found); + if (voted.has(found.id)) setPhase('done'); + }); + return () => { + live = false; + }; + }, []); + + if (!poll) return null; + + const submit = async () => { + setPhase('sending'); + setError(null); + let status = 0; + try { + const response = await fetch(`/api/polls/${poll.id}/votes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ choices, context: poll.context, device: deviceToken() }), + }); + status = response.status; + } catch { + status = 0; + } + if (status >= 200 && status < 300) { + voted.add(poll.id); + setPhase('done'); + return; + } + if (status === 409) voted.add(poll.id); + setError(ERRORS[status] ?? 'Could not send your vote. Try again.'); + setPhase('voting'); + }; + + return ( +
+

Vote in this meeting's poll

+ {phase === 'done' ? ( +

Thanks, your vote is in.

+ ) : ( + <> + {poll.ballot.categories.map((category, i) => + category.candidates?.length ? ( +
+ {categoryTitle(category)} + {category.candidates.map((name, j) => ( + + ))} +
+ ) : null + )} + {error &&

{error}

} + + + )} +
+ ); +} diff --git a/apps/zoom-app/src/components/ReportTab.jsx b/apps/zoom-app/src/components/ReportTab.jsx index 563ddbf..182cb61 100644 --- a/apps/zoom-app/src/components/ReportTab.jsx +++ b/apps/zoom-app/src/components/ReportTab.jsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useEffect } from 'react'; import { useTimer } from '../context/TimerContext'; import { useToast } from '../context/ToastContext'; import { Copy, Check, Trash2, X, History, CloudOff, ChevronLeft, Share2 } from 'lucide-react'; @@ -6,7 +6,7 @@ import { useState } from 'react'; import BrandedReportHeader from '@toastmaster-timer/ui/BrandedReportHeader'; import ReportHistoryList from '@toastmaster-timer/ui/ReportHistoryList'; import ShareStep from '@toastmaster-timer/ui/ShareStep'; -import { fetchHistory, fetchMeeting, endMeetingAndShare, copyReportImage } from '@toastmaster-timer/shared'; +import { fetchHistory, fetchMeeting, endMeetingAndShare, copyReportImage, loadPollResults, savePollResult, recordPollResults, categoryTitle } from '@toastmaster-timer/shared'; import ConfirmModal from './ConfirmModal'; import { useClub, useOutboxPending } from '../hooks/useClub'; import { getSessionToken } from '../utils/zoomIdentity'; @@ -44,6 +44,38 @@ export default memo(function ReportTab() { const [openMeeting, setOpenMeeting] = useState(null); // null | { phase: 'title'|'working'|'done', title, result } const [share, setShare] = useState(null); + // This meeting's closed polls (POLL tab), oldest first. + const [polls, setPolls] = useState(() => loadPollResults()); + + // A native Zoom poll's answers arrive after the meeting ends (the Worker + // fetches them on meeting.ended). Picked up here, once, and archived like the + // poll they belong to. + useEffect(() => { + const token = getSessionToken(); + const waiting = loadPollResults().filter((poll) => poll.zoomPoll && !poll.zoom); + if (!token || !waiting.length) return; + let alive = true; + (async () => { + for (const poll of waiting) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await fetch(`/api/polls/${poll.pollId}/results`, { headers: { Authorization: `Bearer ${token}` } }); + // eslint-disable-next-line no-await-in-loop + const body = response.ok ? await response.json() : null; + if (!body?.zoom) continue; + const merged = { ...poll, zoom: body.zoom }; + savePollResult(merged); + recordPollResults(merged, { getToken: getSessionToken }); + } catch { + // Offline: the next visit to this tab tries again. + } + } + if (alive) setPolls(loadPollResults()); + })(); + return () => { + alive = false; + }; + }, []); // A lapsed club keeps its cached record (the device still knows which club to // re-check) but stops archiving, so the strip and History go with it. @@ -53,7 +85,7 @@ export default memo(function ReportTab() { const endMeeting = async () => { const title = share?.title ?? ''; setShare({ phase: 'working', title, result: null }); - const result = await endMeetingAndShare({ title, speeches: reports, getToken: getSessionToken }); + const result = await endMeetingAndShare({ title, speeches: reports, polls, getToken: getSessionToken }); setShare({ phase: 'done', title, result }); trackEvent('meeting_ended', { speeches: result.speeches, @@ -183,6 +215,7 @@ export default memo(function ReportTab() { // and clearing this device's scratch list must not reach into it. const handleConfirmClear = () => { clearAllReports(); + setPolls([]); setShowClearConfirm(false); }; @@ -218,6 +251,30 @@ export default memo(function ReportTab() { )} + {polls.length > 0 && ( +
+

Poll winners

+ {polls.flatMap((poll) => poll.categories ?? []).map((category, i) => ( +
+ {categoryTitle(category)} + + {category.winners?.length ? category.winners.join(', ') : 'No votes'} + +
+ ))} + {polls.filter((poll) => poll.zoom).map((poll) => ( +
+
Zoom poll
+ {(poll.zoom.questions ?? []).map((question, i) => ( +
+ {question.name}: {Object.entries(question.answers ?? {}).map(([answer, n]) => `${answer} ${n}`).join(', ')} +
+ ))} +
+ ))} +
+ )} + {reports.length === 0 ? (
No reports yet. Complete speeches in the LIVE tab to generate reports. diff --git a/apps/zoom-app/src/components/TimerStage.jsx b/apps/zoom-app/src/components/TimerStage.jsx index 20ecc31..72ea67b 100644 --- a/apps/zoom-app/src/components/TimerStage.jsx +++ b/apps/zoom-app/src/components/TimerStage.jsx @@ -79,6 +79,7 @@ export default memo(function TimerStage({ onRenameSpeaker, clubKit, badgePlacement, + pollWinners, }) { const phaseInfo = rules ? getPhaseInfo(elapsedTime, rules, status) : null; const phaseText = phaseInfo ? formatPhaseTextFor(phaseInfo, rules) : ''; @@ -221,10 +222,26 @@ export default memo(function TimerStage({
- {/* Deliberately empty: the color is the signal. A speaker watching a shared + {/* Empty but for the poll winners: the color is the signal. A speaker watching a shared screen should be reading green/yellow/red the way they would read timing cards in the room, not a clock counting at them. */} -
+
+ {/* The closed poll's winners, put here by "Show on Stage" on the POLL + tab. Only this device's stage: the poll owner chooses to share it. */} + {pollWinners?.length > 0 && ( +
+
Winners
+ {pollWinners.map((category, i) => ( +
+
{category.label || category.role}
+
+ {category.winners?.length ? category.winners.join(', ') : 'No votes'} +
+
+ ))} +
+ )} +
{/* Organizer instrumentation, grouped at the bottom. Small on purpose: readable to whoever is running the timer, easy for the speaker to diff --git a/apps/zoom-app/src/components/TimerStage.test.jsx b/apps/zoom-app/src/components/TimerStage.test.jsx new file mode 100644 index 0000000..cf25afe --- /dev/null +++ b/apps/zoom-app/src/components/TimerStage.test.jsx @@ -0,0 +1,85 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import TimerStage from './TimerStage'; + +/** + * The poll winners on the stage (#80, AC-8): "Show on Stage" hands the closed + * poll's categories to the owner's own TimerStage as `pollWinners` + * (`[{role, label?, winners: [name, …]}]`), and the stage shows them as plain + * React over the artwork. No prop, no panel. + */ + +// Stubbed rather than imported: the real module pulls in @zoom/appssdk, which +// hangs vitest under jsdom. +vi.mock('../utils/zoomSdk', () => ({ + getBackgroundUrl: vi.fn((color) => `/backgrounds/${color}.png`), + getZoomParticipants: vi.fn(async () => []), +})); + +const noop = () => {}; + +function renderStage(extra = {}) { + return render( + + ); +} + +const WINNERS = [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', winners: ['Bob', 'Carol'] }, + { role: 'Standard Speech', label: 'Best Speaker', winners: ['Alice'] }, + { role: 'Table Topics Evaluation', winners: [] }, +]; + +describe('the winners panel on the stage', () => { + it('names each category\'s winners, every tied name included', () => { + renderStage({ pollWinners: WINNERS }); + + expect(screen.getAllByText(/Best Table Topics Speaker/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Bob/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Carol/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Best Speaker/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Alice/).length).toBeGreaterThan(0); + }); + + it('names an unlabelled category by its role, and says "No votes" when nobody voted in it', () => { + renderStage({ pollWinners: WINNERS }); + + expect(screen.getAllByText(/Table Topics Evaluation/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/No votes/i).length).toBeGreaterThan(0); + }); + + it('shows no panel without winners', () => { + renderStage(); + expect(screen.queryByText(/winners/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/No votes/i)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/zoom-app/src/utils/zoomSdk.js b/apps/zoom-app/src/utils/zoomSdk.js index 9a1e1e4..831b0d4 100644 --- a/apps/zoom-app/src/utils/zoomSdk.js +++ b/apps/zoom-app/src/utils/zoomSdk.js @@ -106,6 +106,9 @@ export const USED_SDK_APIS = [ { name: 'onAppVisibilityChange', capability: 'onAppVisibilityChange', required: false, purpose: 'Noticing background changes' }, { name: 'onMyMediaChange', capability: 'onMyMediaChange', required: false, purpose: 'Overlay sizing' }, { name: 'openUrl', capability: 'openUrl', required: false, purpose: 'Marketplace review link, Stripe checkout and billing' }, + { name: 'sendMessageToChat', capability: 'sendMessageToChat', required: false, purpose: 'Posting the poll\'s voting link to the meeting chat' }, + { name: 'sendAppInvitationToAllParticipants', capability: 'sendAppInvitationToAllParticipants', required: false, purpose: 'Inviting everyone into the app to vote' }, + { name: 'getMeetingContext', capability: 'getMeetingContext', required: false, purpose: 'The meeting number a native Zoom poll is created on' }, ]; const capabilitiesWhere = (required) => @@ -2279,6 +2282,67 @@ export async function readZoomUserSummary() { } } +/** + * The meeting's number and topic, for the native Zoom poll: Zoom's REST API + * creates polls on the numeric meeting ID, which the app context does not carry. + * + * @returns {Promise<{meetingID: string|null, meetingTopic: string|null}>} nulls when the client cannot say + */ +export async function readMeetingContext() { + const empty = { meetingID: null, meetingTopic: null }; + await initializeZoomSdk(); + if (!sdkAvailable || !isApiAvailable('getMeetingContext')) return empty; + try { + const context = await zoomSdk.getMeetingContext(); + return { + meetingID: context?.meetingID != null ? String(context.meetingID) : null, + meetingTopic: typeof context?.meetingTopic === 'string' ? context.meetingTopic : null, + }; + } catch (error) { + log(`Could not read the meeting context: ${error.message || error.name}`, 'warn'); + return empty; + } +} + +/** + * Post a message to the meeting chat, as this user. Used for the poll's voting + * link; a client that refused the capability answers false, and the caller + * falls back to Copy link. + * + * @param {string} message + * @returns {Promise} whether Zoom took it + */ +export async function sendChatMessage(message) { + await initializeZoomSdk(); + if (!sdkAvailable || !isApiAvailable('sendMessageToChat')) return false; + try { + await zoomSdk.sendMessageToChat({ message }); + return true; + } catch (error) { + log(`Could not post to the meeting chat: ${error.message || error.name}`, 'warn'); + return false; + } +} + +/** + * Invite every participant into this app, so they can vote in the open poll. + * The invitation carries no data: the invited app finds the poll itself + * (POST /api/polls/current). + * + * @returns {Promise} whether Zoom sent it + */ +export async function inviteAllParticipants() { + await initializeZoomSdk(); + if (!sdkAvailable || !isApiAvailable('sendAppInvitationToAllParticipants')) return false; + try { + await zoomSdk.sendAppInvitationToAllParticipants(); + return true; + } catch (error) { + log(`Could not invite everyone into the app: ${error.message || error.name}`, 'warn'); + return false; + } +} + /** * Camera resolution reported by the client, or null if never reported. * @returns {{width: number, height: number}|null} diff --git a/apps/zoom-app/src/utils/zoomSdk.test.js b/apps/zoom-app/src/utils/zoomSdk.test.js index fbf69f8..13fe414 100644 --- a/apps/zoom-app/src/utils/zoomSdk.test.js +++ b/apps/zoom-app/src/utils/zoomSdk.test.js @@ -4928,3 +4928,19 @@ describe('the cropped count-up layer', () => { expect(sdkMock.setVirtualBackground).toHaveBeenCalledWith({ fileUrl: GREEN }); }); }); + +// Meeting polls (#80, AC-12, AC-15): posting the voting link to chat, +// inviting everyone into the app, and the meeting number the native poll is +// created on. All optional: a client that refuses them still runs the timer, +// and the poll falls back to Copy link. +describe('the SDK APIs meeting polls use', () => { + it('declares sendMessageToChat, sendAppInvitationToAllParticipants and getMeetingContext as optional capabilities', async () => { + const { USED_SDK_APIS } = await loadModule(); + for (const name of ['sendMessageToChat', 'sendAppInvitationToAllParticipants', 'getMeetingContext']) { + const api = USED_SDK_APIS.find((entry) => entry.name === name); + expect(api, `${name} is in USED_SDK_APIS`).toBeDefined(); + expect(api.capability).toBe(name); + expect(api.required).toBe(false); + } + }); +}); diff --git a/docs/CLUBS.md b/docs/CLUBS.md index d049c2c..21f29a9 100644 --- a/docs/CLUBS.md +++ b/docs/CLUBS.md @@ -91,6 +91,9 @@ clubState = { presets: { rules, order, hiddenBuiltins, publishedBy, publishedAt } | null, badge: { x, y, scale }, ← the club's default placement cards: { label, images: { color: sha256 }, publishedBy, publishedAt } | null, + pollTemplate: { categories: [{ role, label?, slots }], + options: { postToChat, showOnStage, expiryHours }, + publishedBy, publishedAt } | null, timezone, role, ← this caller's role, or null plan, entitled, status, currentPeriodEnd, cancelAtPeriodEnd, source @@ -113,6 +116,7 @@ toastmaster_club { clubToken, ver, club, kit, badge, plan, toastmaster_club_presets the club's published list toastmaster_preset_source 'club' | 'personal' toastmaster_club_cards the club's card set: label and image hashes +toastmaster_club_poll_template the club's meeting poll template toastmaster_card_source 'club' | 'own' toastmaster_club_badge this device's badge move, if it made one toastmaster_club_outbox finished speeches not yet uploaded @@ -153,6 +157,20 @@ from the console. Publishing prunes images no set names any more, sparing uploads newer than 15 minutes so a second publisher's uploads survive until their own publish lands. +## Poll template: the club's ballot setup + +An admin or editor publishes the meeting poll setup (categories, slots, and +the post-to-chat, Stage and expiry options) from the Zoom app's **POLL** tab +with **Publish to club**: `PUT /api/club/poll-template` stores +`club-poll-template:` and moves `ver`. Club devices set new polls up from +it while the club is entitled; a lapsed club keeps it stored but unused, as +with presets. Editing the ballot for one meeting never touches it. + +Closed poll results go through the outbox like speeches and land under the +meeting as `poll:::`: a key of its own, because +compaction rewrites the meeting header from the fields it lists. Both meeting +reads, and the shared `/r/` page, merge them back in as `polls`. + ## KV inventory All in the `PROFILES` namespace, alongside the entitlement and Stripe prefixes. @@ -171,12 +189,15 @@ club-presets: { rules, order, hiddenBuiltins, badge, publishedBy, publishedAt } club-cards: { label, images: { color: sha256 }, publishedBy, publishedAt } +club-poll-template: { categories, options, publishedBy, publishedAt } club-device:: { label, uid|null, activatedAt, lastSeenAt, revokedAt } club-member::zoom: { role, displayName, addedAt, revokedAt } club-magic: { clubId, email, exp } 15-minute TTL meeting::: { title, date, startedAt, speeches?: [...] } ↳ KV metadata { speeches, overtime, title } speech::: one finished speech, before compaction +poll::: one closed meeting poll: per category its + candidates, counts, byPath and winners report-share: { clubId, meetingId } report-share-by-meeting:: ← so a re-share reuses the address @@ -201,10 +222,12 @@ PUT /api/club/cards/ X-Club + uid, role admin|editor (one i PUT /api/club/cards X-Club + uid, role admin|editor (the set) GET /api/club/cards/ X-Club, or an admin's console session DELETE /api/club/cards admin +PUT /api/club/poll-template X-Club + uid, role admin|editor POST /api/club/meetings//speeches X-Club, + device revocation read GET /api/club/meetings X-Club GET /api/club/meetings/ X-Club POST /api/club/meetings//share X-Club +POST /api/club/meetings//polls X-Club, + device revocation read GET /api/club/roster admin POST /api/club/members//role admin POST /api/club/devices//revoke admin @@ -339,7 +362,8 @@ Then clear `toastmaster_club`'s `lastRefreshAt` (or wait a day) and reload. `club_presets_published` · `club_presets_reset` · `club_presets_forked` · `club_admin_opened` · `club_role_changed` · `club_access_revoked` · `brand_badge_moved` · `meeting_ended` · `report_shared` · -`report_history_viewed` · `club_grace_banner_shown` · `club_lapsed_shown` +`report_history_viewed` · `club_grace_banner_shown` · `club_lapsed_shown` · +`poll_template_published` (Worker) All carry `club_id` and `surface` (`zoom` or `web`). The existing `upgrade_prompt_shown` carries `plan_source` in `subscription | grant | club`, diff --git a/docs/FEATURE_FLAGS.md b/docs/FEATURE_FLAGS.md index 88c2d9d..7caf9cc 100644 --- a/docs/FEATURE_FLAGS.md +++ b/docs/FEATURE_FLAGS.md @@ -54,10 +54,20 @@ Server gates: flagEnabled(env, key, { uid }) → bare 404 when off ## The flags -There are two flags, `pro` and `contact_capture`, declared in `FLAG_FALLBACKS` -in `worker/flags.js`, each with a `removeBy` date in a comment. Both fallbacks -are `false`: the safe value, not the current one. **Both must exist in PostHog -before they can be turned on in production**; until then the fallback applies. +The flags are declared in `FLAG_FALLBACKS` in `worker/flags.js`, each with a +`removeBy` date in a comment. Every fallback is `false`: the safe value, not the +current one. **Each must exist in PostHog before it can be turned on in +production**; until then the fallback applies. + +`polls` gates meeting polls: the Zoom app's **POLL** tab and `POST /api/polls` +(after the session check, so no session is still 401). Voting, the vote page +`/vote/` and the club poll template are not gated: with the flag off no +poll can be opened to vote in. `polls_zoom` gates the native Zoom poll on top +of it (**Create as Zoom poll**, `POST /api/polls/zoom`). It stays off in +production until Zoom has reviewed the scopes `meeting:write:poll`, +`meeting:read:meeting` and `meeting:read:list_poll_results`, and the SDK APIs +`sendMessageToChat`, `sendAppInvitationToAllParticipants` and +`getMeetingContext` are in the production app. `contact_capture` gates one thing: the Zoom app asking Zoom for an authorization code (`zoomSdk.authorize`) so the Worker can save the user's Zoom diff --git a/docs/ZOOM_TEST_PLAN.md b/docs/ZOOM_TEST_PLAN.md index 04a6619..e462b27 100644 --- a/docs/ZOOM_TEST_PLAN.md +++ b/docs/ZOOM_TEST_PLAN.md @@ -415,8 +415,20 @@ On a club device, in a meeting with the camera on: 4. **Share** — **End meeting & share** produces a branded image and a link. Paste the link into Zoom chat: expected a preview card, and the page opens for someone with no club and no account. -5. Confirm on a **free** device that none of the above appears, and that the - timer is otherwise identical. +5. **Meeting polls** — open the **POLL** tab after timing a few speeches. + Expected: the ballot lists this meeting's speakers per category. **Open + poll** shows a voting link and posts it to the meeting chat; + **Invite everyone** opens the app for the other participants, who can vote + in it on any plan. **Close poll** shows each category's winners. The host + of a scheduled meeting also sees **Create as Zoom poll**, which asks for + Zoom authorization and adds the same questions as a Zoom poll. + Scopes this uses: `meeting:write:poll`, `meeting:read:meeting` and + `meeting:read:list_poll_results` (the native poll's answers, fetched once + after the meeting ends). SDK APIs: `sendMessageToChat`, + `sendAppInvitationToAllParticipants`, `getMeetingContext`, `authorize`. +6. Confirm on a **free** device that none of the above appears, and that the + timer is otherwise identical. A free participant can still vote in a poll + someone else opened. ## Step P6: Manage or cancel billing diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index 14fea04..e3e461f 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -4,7 +4,7 @@ Read this first. Update it in the same PR as any spec change. | Spec | Status | Verified | Covers | |---|---|---|---| -| [timer-core](timer-core.md) | current | 905d8d1, 2026-11-13 | start/colours/finish, resume after close; `audience_size` on `timer_started` for hosts, omitted for non-hosts (#107); the Live tab's suggestions, stage picker and FINISH refuse an agenda row that needs a role (#91 follow-up). TC-09 and TC-12 (human) not run | +| [timer-core](timer-core.md) | stale | 905d8d1, 2026-11-13 | start/colours/finish, resume after close; `audience_size` on `timer_started` for hosts, omitted for non-hosts (#107); the Live tab's suggestions, stage picker and FINISH refuse an agenda row that needs a role (#91 follow-up). TC-09 and TC-12 (human) not run | | [agenda-import](agenda-import.md) | current | b029a29, 2026-11-13 | Simple Format import takes exact role names from the bracket text and highlights the rest for a manual pick; imported and picked roles' thresholds on real video (#77, #91) | | [zoom-deeplink](zoom-deeplink.md) | draft | never | "Open in Zoom" deeplink resolves and launches this build's app (dev vs production client id) | | auth-identity | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` Steps 1, 1b | @@ -12,7 +12,8 @@ Read this first. Update it in the same PR as any spec change. | [reauth-landing](reauth-landing.md) | stale | never | install landing: `tt_approved` mark, `?welcome=new` / `?welcome=back`, "You're reconnected" and Open in Zoom; a marked browser's bare `/` redirects to `/reconnect` (`/?home` and the `zoom.` host do not); where Zoom lands a guest after approving (#96) | | [contact-capture](contact-capture.md) | draft | never | Zoom email/name capture: authorize, "Stay in touch" card, browser and web sign-in doors, purge on uninstall (#83) | | [timer-sounds](timer-sounds.md) | draft | never | Pro sounds: card-change chime (1/2/3 notes), break music during Take a Break, locked for free plans, audible through Share sound (#105) | -| [club-card-sets](club-card-sets.md) | current | 905d8d1, 2026-11-13 | an admin or editor shares a custom card set with the club; another club device downloads it and shows it on real video; own choice kept; members can't publish; console removal; lapse; republish prunes the old image (plan 10 Phase 1). CC-07 and CC-08 not run on 905d8d1, and CC-06's member 403 never yet (a guest got 401) | +| [club-card-sets](club-card-sets.md) | stale | 905d8d1, 2026-11-13 | an admin or editor shares a custom card set with the club; another club device downloads it and shows it on real video; own choice kept; members can't publish; console removal; lapse; republish prunes the old image (plan 10 Phase 1). CC-07 and CC-08 not run on 905d8d1, and CC-06's member 403 never yet (a guest got 401) | +| [meeting-polls](meeting-polls.md) | stale | never | Pro meeting polls (#80): ballot from this meeting's speakers, open with chat post and "Invite everyone", in-Zoom votes by verified uid and guests, the `/vote/` backup link, close and reveal (Stage, Report tab, club archive, report image, `/r/`), club and personal templates, the native Zoom poll and its results after `meeting.ended`. Not yet run | | pro-club | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` P1–P7, plus the internal Club Pro plan (`06-zoom-client-test-plan-club-pro.md`); reference run `test-runs/2026-09-28-club-pro/run.md` | | webhooks-lifecycle | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` Steps 4, 5, 6 | @@ -21,5 +22,5 @@ yet. Migrate one at a time, running each once so `verified_at` is real. ## Run order for a full pass Dependencies between specs, so the skill can batch human steps: `auth-identity` → -`connection-notice` → `reauth-landing` → `contact-capture` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `webhooks-lifecycle` (deauth +`connection-notice` → `reauth-landing` → `contact-capture` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `meeting-polls` → `webhooks-lifecycle` (deauth last, it removes the app). diff --git a/e2e/zoom-client/club-card-sets.md b/e2e/zoom-client/club-card-sets.md index a029f5b..dcb3646 100644 --- a/e2e/zoom-client/club-card-sets.md +++ b/e2e/zoom-client/club-card-sets.md @@ -1,7 +1,7 @@ --- id: club-card-sets title: Club card sets (share card artwork with the club) -status: current +status: stale covers: - worker/club-cards.js - packages/shared/clubCards.js @@ -105,6 +105,9 @@ Evidence: `PUT /api/club/cards` → 200; KV `club-cards:` names only the `ver` went up by 1; `GET /api/club/cards/` with a club token → 404 (pruned). ## Not covered +- Meeting polls (#80): `club.js` also stores the club's poll template, `club-cards.js` shares its + publisher check with it, and `LiveTab.jsx` passes poll winners to the Stage. Reviewed + 2026-11-15: the card set flow is unchanged; see [meeting-polls](meeting-polls.md). Rerun pending. - Sharing from the web timer: same shared module and endpoints, proven by unit tests. - Two publishers racing: the prune grace window and the client's retry are unit-tested. - Card sets are one per club for now (open question 2 in the plan). diff --git a/e2e/zoom-client/contact-capture.md b/e2e/zoom-client/contact-capture.md index a370ce9..71d8554 100644 --- a/e2e/zoom-client/contact-capture.md +++ b/e2e/zoom-client/contact-capture.md @@ -125,5 +125,8 @@ Reviewed against #88 (2026-11-01): its `zoomSdk.js` changes are the overlay queu frame only; nothing in authorize, `onAuthorized` or the contact flow. No step changes. ## Not covered +- Meeting polls (#80) added three optional APIs to `zoomSdk.js` and reuse + `requestZoomAuthorizeCode` for the native Zoom poll. Reviewed 2026-11-15: contact capture is + unchanged; see [meeting-polls](meeting-polls.md) MP-13, MP-14. - Zoom clients older than the one that supports `authorize` / `onAuthorized`. - Accounts where the admin blocks the scopes. diff --git a/e2e/zoom-client/meeting-polls.md b/e2e/zoom-client/meeting-polls.md new file mode 100644 index 0000000..bc6020a --- /dev/null +++ b/e2e/zoom-client/meeting-polls.md @@ -0,0 +1,163 @@ +--- +id: meeting-polls +title: Meeting polls (Best Table Topics Speaker, Best Speaker, Best Evaluator) +status: stale +covers: + - apps/zoom-app/src/App.jsx + - apps/zoom-app/src/components/NavTabs.jsx + - apps/zoom-app/src/components/PollTab.jsx + - apps/zoom-app/src/components/PollVote.jsx + - apps/zoom-app/src/components/ReportTab.jsx + - apps/zoom-app/src/components/TimerStage.jsx + - apps/zoom-app/src/utils/zoomSdk.js + - packages/shared/club.js + - packages/shared/clubArchive.js + - packages/shared/polls.js + - packages/shared/reportImage.js + - worker/club-meetings.js + - worker/club-poll-template.js + - worker/club-share.js + - worker/poll-room.js + - worker/polls.js + - worker/polls-zoom.js + - worker/zoom-webhook.js +automated: + - worker/polls.test.js + - worker/polls-zoom.test.js + - worker/club-poll-template.test.js + - packages/shared/__tests__/polls.test.js + - apps/zoom-app/src/components/PollTab.test.jsx + - apps/zoom-app/src/components/TimerStage.test.jsx + - e2e/zoom.meeting-polls.spec.js +needs: + - dev deployed (this branch, `npm run cf:deploy:dev`) + - FLAGS_FORCE "1" on dev, so `polls` and `polls_zoom` are on + - Zoom account A on Pro, host of the test meeting, an editor of a dev club (club-member record) + - Zoom account B, a second participant on another computer, on the free plan + - a guest device with `defaults write ZoomChat enableGuestModeTesting true`, Zoom restarted (MP-17) + - a scheduled (not instant) test meeting for MP-13, MP-14 and MP-18 + - the Debug Panel's SDK Status (as CC-00) shows sendMessageToChat, getMeetingContext, sendAppInvitationToAllParticipants, authorize, onAuthorized and promptAuthorize granted + - ENTITLEMENT_ENFORCE "1" on dev for MP-01 only (ask first; revert after) +verified_at: never +verified_on: never +--- + +# Meeting polls (Best Table Topics Speaker, Best Speaker, Best Evaluator) + +Issue #80. A Pro organizer builds a ballot from this meeting's timed speakers, opens it, +invites everyone into the app to vote with their verified Zoom identity (the web link +`/vote/` is the backup), closes it and reveals the winners on the sidebar, the Stage, +the Report tab, the report image and the shared `/r/` page. A host can also create it as a +native Zoom poll, whose results the Worker fetches after the meeting. Unit and Playwright +tests prove the logic, the Worker's checks and the UI in a plain browser. Only the real +client proves the SDK bridge (chat, invite, meeting context, authorize), the real Zoom +identities of a second participant and a guest, Zoom's own poll window and the +meeting.ended webhook. + +## Purpose +Catches a poll that cannot be opened from inside Zoom, a voting link that points at the +`zoom.` host (which routes every path to the sidebar app), a chat post or invite that +fails silently, a vote counted twice for one Zoom account, a guest who cannot vote, a free +plan that can run a poll or cannot vote, winners that never reach the Stage, the Report +tab or the club archive, and a native poll whose results never come back after the meeting. + +## Steps + +### MP-01 · auto · A free plan sees the setup locked, and can still vote +Do: with ENTITLEMENT_ENFORCE "1", sign in as a free account, open the app, click the POLL tab. +Expect: the POLL tab sits after REPORT; the setup is replaced by "Upgrade to Pro", which opens the upgrade modal. No "Open poll" button. +Evidence: `upgrade_prompt_shown` with `source: "polls"` in PostHog; a direct `POST /api/polls` with this session answers 402 `upgrade_required`. + +### MP-02 · auto · The ballot is built from this meeting's timed speakers +Do: as A, time two Table Topics speakers (Bob, Carol, then Bob again) and one Standard Speech (Alice), then open the POLL tab. +Expect: three categories, Table Topics Speech, Standard Speech, Table Topics Evaluation; Table Topics lists Bob, Carol (once each, in timing order); Standard Speech lists Alice; Table Topics Evaluation shows three empty slots. +Evidence: `toastmaster_reports` in localStorage holds the four speeches; screenshot of the POLL tab. + +### MP-03 · auto · Categories, candidates and a new role +Do: add a category, set its role to a custom role, rename a candidate, add one, remove one, remove the category; then choose "New role…" in a category's role select, name it "Humorous Speech Contest" and add it. +Expect: each edit shows at once; the new role becomes that category's role and appears in the LIVE tab's role list with the default custom rules. +Evidence: `toastmaster_role_rules` gains "Humorous Speech Contest"; `role_added` in PostHog. + +### MP-04 · human · The voting link is posted to the meeting chat +Do: with "Post the voting link in the meeting chat" on, click "Open poll". The user confirms the chat message (it sends a message to the meeting). +Expect: the sidebar shows `https://www.timer-dev.simple-tech.app/vote/` with Copy link; the meeting chat shows the same link once. With it off, nothing is posted and Copy link still shows. +Evidence: `POST /api/polls` 201 in the Worker log; `poll_opened` in PostHog; screenshot of the chat. + +### MP-05 · human · A second device votes through the link +Do: on a phone or another browser with no Zoom sign-in, open the link, pick a candidate, submit; then reload and try to vote again in the same category. +Expect: the ballot shows the names and labels, no counts, and the "one vote per device" note; the first vote says thanks; the second is refused ("already voted"). +Evidence: Worker log `POST /api/polls//votes` 200 then 409; `poll_vote_cast` with `path: "link"`. + +### MP-06 · auto · Only the owner sees counts, and they update +Do: keep the POLL tab open on A while MP-05 votes arrive; then request `GET /api/polls//results` with B's session and with none. +Expect: A's counts update within about 5 s, with an "in Zoom / guest / link" split; B gets 403, no session gets 401. +Evidence: Worker log of the 5-second results reads; the two refused requests. + +### MP-07 · auto · Close and reveal +Do: as A, click "Close poll". +Expect: each category shows only its winners; a tie shows every tied name; a category with no votes says "No votes". Votes after closing are refused. +Evidence: `POST /api/polls//close` 200; `poll_closed` in PostHog; `toastmaster_poll_results` in localStorage holds the winners. + +### MP-08 · auto · The winners on the shared Stage +Do: as A, click "Show on Stage", switch to Stage mode and share it to the meeting. +Expect: the shared Stage shows the winners panel over the artwork, as seen from B's screen; nobody else's Stage changes. +Evidence: screenshot from B's machine, or from A's share preview. + +### MP-09 · auto · Results on the Report tab and in the club archive +Do: on A (a club device), open the REPORT tab after MP-07; then read the meeting from the club archive. +Expect: the Report tab shows a results card with each category's winners; `GET /api/club/meetings/` returns them under `polls`, before and after the meeting is compacted. +Evidence: Worker log `POST /api/club/meetings//polls` 200; KV key `poll:::`. + +### MP-10 · auto · An editor publishes the ballot setup to the club +Do: as A (editor), click "Publish to club"; on a member device of the same club, reopen the app so the club refreshes. +Expect: the member's POLL tab starts from the published categories. +Evidence: `PUT /api/club/poll-template` 200 and the club's `ver` bumped; `toastmaster_club_poll_template` on the member device. + +### MP-11 · auto · A member edits for one meeting only +Do: on the member device, rename a candidate and open a poll. +Expect: the club template and the member's stored copy are unchanged; the member sees no "Publish to club" button. +Evidence: `toastmaster_club_poll_template` byte-identical before and after; no `PUT /api/club/poll-template` from the member. + +### MP-12 · auto · A personal template syncs +Do: as a Pro account with no club, click "Save as my template"; open the app on a second device signed in to the same account. +Expect: the second device's POLL tab starts from the saved categories. +Evidence: `toastmaster_poll_template` in the `/api/profile` PUT body and on the second device. + +### MP-13 · human · "Create as Zoom poll" is the host's, and refuses an instant meeting +Do: as A (host) in an instant meeting, open a poll and click "Create as Zoom poll"; approve Zoom's authorization prompt if shown. Then open the app as B. +Expect: the button and Zoom's limits (host only, Zoom Pro with polling on, scheduled meetings only) show for A only; on the instant meeting the app says it cannot create a poll there. +Evidence: `POST /api/polls/zoom` 4xx with the instant-meeting reason; no `POST /meetings/{id}/polls` in the Worker log. + +### MP-14 · human · On a scheduled meeting the native poll is created +Do: repeat MP-13 in the scheduled test meeting; open Zoom's Polls window. +Expect: one single-choice question per category, the candidates as answers; A can launch it from Zoom's window. +Evidence: `zoom_poll_created` in PostHog; Worker log `POST /meetings/{id}/polls` 201; the refresh token appears in no log line. + +### MP-15 · human · The open poll is found in the meeting, and "Invite everyone" reaches B +Do: as A, click "Invite everyone". The user confirms on B's machine that the invitation arrives and opens the app. +Expect: B's app shows "Vote now" and opens the POLL tab on the ballot; A's own app shows no "Vote now". +Evidence: `poll_invite_sent` in PostHog; B's `POST /api/polls/current` 200. + +### MP-16 · human · A second participant votes inside Zoom, once per account +Do: on B, vote in one category; then try the same category again from B's account on another device. +Expect: the first vote says "Thanks, your vote is in" with no counts; the second is refused. A's split counts it under "in Zoom". +Evidence: Worker log votes 200 then 409; `poll_vote_cast` with `path: "zoom"`. + +### MP-17 · human · A guest votes +Do: on the guest device (Guest Mode Testing on), join the meeting as a guest and open the app. +Expect: the guest finds the poll and votes; A's split counts it under "guest". +Evidence: `poll_vote_cast` with `path: "guest"`. + +### MP-18 · human · The native poll's results arrive after the meeting +Do: after MP-14, launch and answer the Zoom poll, end the scheduled meeting, wait 6 minutes, open the REPORT tab on A. +Expect: the Report tab shows the Zoom poll's answer counts per question. +Evidence: `zoom_poll_results_fetched` in PostHog; Worker log `GET /past_meetings/{uuid}/polls` 200 about 5 minutes after `meeting.ended`. + +### MP-19 · auto · The report image and the shared page show the winners +Do: on A (club device), after MP-07, use "End meeting & share", then open the `/r/` link and the downloaded image. +Expect: both list each category with its winners (ties joined, "No votes" for an empty one); a meeting without a poll looks as before. +Evidence: the `/r/` HTML; the PNG from `/api/club-assets//r--full.png`. + +## Not covered +- Deliberate double voting through the web link from several browsers: one vote per device is documented as not proof against it, and the per-poll-and-address limit (120 a minute) is proven by unit tests only. +- The 24-hour deletion of the stored refresh token when no meeting.ended arrives, and the +15/+60 minute retries: unit tests (`worker/polls-zoom.test.js`) only. diff --git a/e2e/zoom-client/timer-core.md b/e2e/zoom-client/timer-core.md index 63a3ac8..d8d7be1 100644 --- a/e2e/zoom-client/timer-core.md +++ b/e2e/zoom-client/timer-core.md @@ -1,7 +1,7 @@ --- id: timer-core title: Core timer in the Zoom client -status: current +status: stale covers: - apps/zoom-app/src/context/TimerContext.jsx - apps/zoom-app/src/components/LiveTab.jsx @@ -163,6 +163,9 @@ TC-10, TC-11 and TC-13 passed with the same substitutions; TC-09 and TC-12 not r Log: test-runs/2026-11-13-pre-master-specs/run.md. ## Not covered +- Meeting polls (#80): `LiveTab.jsx` passes the poll winners to the Stage, and `zoomSdk.js` + gained three optional APIs (chat, invite, meeting context). Reviewed 2026-11-15: no step here + changes; the winners panel and the APIs are in [meeting-polls](meeting-polls.md). Rerun pending. - Colour accuracy on every camera/background setting. - A second participant seeing the video (needs a second computer). - Choosing the background you return to between speeches. Its picker lives only in Card diff --git a/e2e/zoom-client/timer-sounds.md b/e2e/zoom-client/timer-sounds.md index cb0f21a..0a4371b 100644 --- a/e2e/zoom-client/timer-sounds.md +++ b/e2e/zoom-client/timer-sounds.md @@ -107,6 +107,8 @@ Evidence: the second participant confirms; note the result for each share path i log. ## Not covered +- Meeting polls (#80, `LiveTab.jsx` passes poll winners to the Stage). Reviewed 2026-11-15: no + effect on sounds; see [meeting-polls](meeting-polls.md). Not rerun for it. - Break music from audio files. Only the synthesized "Music box" track ships so far; licensed tracks under `apps/zoom-app/public/music/` will add file playback, which needs its own step. diff --git a/e2e/zoom.meeting-polls.spec.js b/e2e/zoom.meeting-polls.spec.js new file mode 100644 index 0000000..e54e55b --- /dev/null +++ b/e2e/zoom.meeting-polls.spec.js @@ -0,0 +1,305 @@ +import { test, expect } from '@playwright/test'; +import * as workerModule from '../worker/index.js'; +import { makeDurableObjectNamespace, makeKv, makeCtx, makeEmptyAssets } from '../worker/durable-object-fake.js'; +import { mintSessionToken } from '../worker/session-token.js'; +import { entitlementKey } from '../worker/entitlements.js'; + +/** + * Meeting polls (#80) in a plain browser. The Zoom app half stubs the Worker + * with page.route; the voting page half runs the real Worker in-process (its + * HTML, its inline script and its API, against an in-memory PollRoom), since + * /vote/ is Worker-rendered and the dev server cannot serve it. + * + * Outside Zoom the app has no context; it still asks POST /api/polls/current + * (the server decides), which is what lets the vote view be stubbed here. + */ + +const POLL_ID = 'ABCDEFGHJKMNPQRS'; +const VOTE_URL = `https://www.timer-dev.simple-tech.app/vote/${POLL_ID}`; +const DEFAULT_ROLES = ['Table Topics Speech', 'Standard Speech', 'Table Topics Evaluation']; + +const report = (name, role, finishedAt) => ({ speechId: `${name}-${finishedAt}`, name, role, duration: '1:30', color: 'green', comments: '', disqualified: false, finishedAt }); +const REPORTS = [ + report('Bob', 'Table Topics Speech', 1_000), + report('Alice', 'Standard Speech', 2_000), + report('Carol', 'Table Topics Speech', 3_000), + report('Bob', 'Table Topics Speech', 4_000), +]; + +async function session(page, { plan = 'pro', flags = { pro: true, polls: true } } = {}) { + await page.route('**/api/zoom/session', (route) => + route.fulfill({ + json: { + identified: true, + uid: 'e2e-polls', + entitlement: plan === 'pro' + ? { plan: 'pro', status: 'active', entitled: true, source: 'subscription' } + : { plan: 'free', status: null, entitled: false, source: 'none' }, + flags, + }, + }) + ); +} + +async function seed(page, entries) { + await page.addInitScript((values) => { + if (sessionStorage.getItem('e2e_seeded')) return; + sessionStorage.setItem('e2e_seeded', '1'); + for (const [key, value] of Object.entries(values)) localStorage.setItem(key, JSON.stringify(value)); + }, entries); +} + +/** No poll open in this meeting, unless a test says otherwise. */ +async function noCurrentPoll(page) { + await page.route('**/api/polls/current', (route) => route.fulfill({ status: 404, json: { error: 'not_found' } })); +} + +/** Fails on an assertion, not a click timeout, when the tab is missing. */ +async function openPollTab(page) { + const tab = page.getByRole('button', { name: 'POLL', exact: true }); + await expect(tab).toBeVisible(); + await tab.click(); +} + +const tabs = (page) => page.getByRole('navigation', { name: 'Tabs' }).getByRole('button'); +const categories = (page) => page.getByRole('group').filter({ has: page.getByRole('combobox', { name: /role/i }) }); +const candidateValues = (group) => + group.getByRole('textbox', { name: /candidate/i }).evaluateAll((inputs) => inputs.map((input) => input.value)); + +// --------------------------------------------------------------------------- + +test.describe('the POLL tab', () => { + test('sits after REPORT with the polls flag on', async ({ page }) => { + await session(page); + await noCurrentPoll(page); + await page.goto('/zoom/'); + await expect(tabs(page)).toHaveText(['AGENDA', 'LIVE', 'REPORT', 'POLL']); + }); + + test('is not there with the polls flag off', async ({ page }) => { + await session(page, { flags: { pro: true } }); + await page.goto('/zoom/'); + await expect(tabs(page)).toHaveText(['AGENDA', 'LIVE', 'REPORT']); + }); + + // AC-1 + test('locks the setup on the free plan behind Upgrade to Pro', async ({ page }) => { + await session(page, { plan: 'free' }); + await noCurrentPoll(page); + await page.goto('/zoom/'); + await openPollTab(page); + + await expect(page.getByRole('button', { name: /upgrade to pro/i }).first()).toBeVisible(); + await expect(page.getByRole('button', { name: /open poll/i })).toHaveCount(0); + }); +}); + +test.describe('a Pro organizer runs a poll', () => { + test.beforeEach(async ({ page }) => { + await session(page); + await noCurrentPoll(page); + await seed(page, { toastmaster_reports: REPORTS }); + }); + + // AC-2, AC-5 + test('the ballot starts from the three usual categories, filled with this meeting\'s speakers', async ({ page }) => { + await page.goto('/zoom/'); + await openPollTab(page); + + await expect(categories(page)).toHaveCount(3); + for (const [i, role] of DEFAULT_ROLES.entries()) { + await expect(categories(page).nth(i).getByRole('combobox', { name: /role/i })).toHaveValue(role); + } + expect(await candidateValues(categories(page).nth(0))).toEqual(['Bob', 'Carol']); + expect(await candidateValues(categories(page).nth(1))).toEqual(['Alice']); + expect(await candidateValues(categories(page).nth(2))).toEqual(['', '', '']); + }); + + // AC-3 + test('categories and candidates can be added and removed', async ({ page }) => { + await page.goto('/zoom/'); + await openPollTab(page); + + await page.getByRole('button', { name: /add category/i }).click(); + await expect(categories(page)).toHaveCount(4); + await categories(page).nth(3).getByRole('button', { name: /remove category/i }).click(); + await expect(categories(page)).toHaveCount(3); + + const topics = categories(page).nth(0); + await topics.getByRole('button', { name: /add candidate/i }).click(); + await expect(topics.getByRole('textbox', { name: /candidate/i })).toHaveCount(3); + await topics.getByRole('button', { name: /remove candidate/i }).first().click(); + await expect(topics.getByRole('textbox', { name: /candidate/i })).toHaveCount(2); + }); + + // AC-7, AC-8, AC-9 + test('open, watch the counts, close, reveal the winners, and find them on the Report tab', async ({ page }) => { + let resultReads = 0; + await page.route('**/api/polls', (route) => + route.request().method() === 'POST' ? route.fulfill({ status: 201, json: { id: POLL_ID, url: VOTE_URL } }) : route.fallback() + ); + await page.route(`**/api/polls/${POLL_ID}/results`, (route) => { + resultReads += 1; + route.fulfill({ + json: { status: 'open', counts: [[resultReads, 1], [1], [0, 0, 0]], byPath: { zoom: [[resultReads, 0], [1], [0, 0, 0]], guest: [[0, 0], [0], [0, 0, 0]], link: [[0, 1], [0], [0, 0, 0]] } }, + }); + }); + await page.route(`**/api/polls/${POLL_ID}/close`, (route) => + route.fulfill({ + json: { + counts: [[2, 2], [3], [0, 0, 0]], + byPath: { zoom: [[1, 1], [2], [0, 0, 0]], guest: [[0, 0], [0], [0, 0, 0]], link: [[1, 1], [1], [0, 0, 0]] }, + winners: [['Bob', 'Carol'], ['Alice'], []], + }, + }) + ); + + await page.goto('/zoom/'); + await openPollTab(page); + await expect(page.getByRole('button', { name: /open poll/i })).toBeVisible(); + await page.getByRole('button', { name: /open poll/i }).click(); + await expect(page.getByText(VOTE_URL).or(page.locator(`input[value="${VOTE_URL}"]`)).first()).toBeVisible(); + await expect(page.getByRole('button', { name: /copy link/i })).toBeVisible(); + + // Every five seconds while the tab is open. + await expect.poll(() => resultReads, { timeout: 15_000 }).toBeGreaterThanOrEqual(2); + + await page.getByRole('button', { name: /close poll/i }).click(); + await expect(page.getByRole('button', { name: /show on stage/i })).toBeVisible(); + await expect(page.getByText(/No votes/i).first()).toBeVisible(); + + await page.getByRole('button', { name: 'REPORT', exact: true }).click(); + await expect(page.getByText(/winners?/i).first()).toBeVisible(); + await expect(page.getByText(/No votes/i).first()).toBeVisible(); + }); +}); + +// AC-14 +test('a participant on the free plan finds the open poll, votes, and is thanked', async ({ page }) => { + const VOTE_ID = 'VWXYZ23456789ABC'; + let voteBody = null; + await session(page, { plan: 'free' }); + await page.route('**/api/polls/current', (route) => + route.fulfill({ json: { id: VOTE_ID, ballot: { categories: [{ role: 'Standard Speech', label: 'Best Speaker', candidates: ['Alice', 'Bob'] }] } } }) + ); + await page.route(`**/api/polls/${VOTE_ID}/votes`, (route) => { + voteBody = route.request().postDataJSON(); + route.fulfill({ json: { ok: true } }); + }); + + await page.goto('/zoom/'); + const banner = page.getByText(/vote now/i).first(); + await expect(banner).toBeVisible(); + await banner.click(); + + await expect(page.getByText('Best Speaker').first()).toBeVisible(); + await page.getByRole('radio', { name: 'Bob' }).check(); + await page.getByRole('button', { name: /submit vote/i }).click(); + + await expect(page.getByText(/thanks, your vote is in/i).first()).toBeVisible(); + expect(voteBody.choices).toEqual({ 0: 1 }); +}); + +// AC-6: the backup link, against the real Worker. +test.describe('the voting page /vote/', () => { + const ORIGIN = 'https://www.timer-dev.simple-tech.app'; + let env; + let ctx; + + const workerFetch = (path, { method = 'GET', uid, body } = {}) => + workerModule.default.fetch( + new Request(`${ORIGIN}${path}`, { + method, + headers: { + host: 'www.timer-dev.simple-tech.app', + 'content-type': 'application/json', + ...(uid ? { authorization: `Bearer ${mintSessionToken(uid, env.SESSION_SIGNING_KEY)}` } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }), + env, + ctx + ); + + test.beforeEach(async ({ page }) => { + env = { + PROFILES: makeKv(), + ASSETS: makeEmptyAssets(), + SESSION_SIGNING_KEY: 'e2e-session-signing-key', + ZOOM_CLIENT_SECRET: 'e2e-zoom-client-secret', + ENTITLEMENT_ENFORCE: '1', + FLAGS_FORCE: '1', + WEB_ORIGIN: ORIGIN, + }; + env.POLL_ROOM = makeDurableObjectNamespace(workerModule.PollRoom, () => env); + ctx = makeCtx(); + await env.PROFILES.put(entitlementKey('owner-uid'), JSON.stringify({ status: 'active', currentPeriodEnd: Date.now() + 1e10 })); + + // Every request to the site goes to the in-process Worker. + await page.route(`${ORIGIN}/**`, async (route) => { + const request = route.request(); + const method = request.method(); + const res = await workerModule.default.fetch( + new Request(request.url(), { + method, + headers: { ...request.headers(), host: 'www.timer-dev.simple-tech.app', 'cf-connecting-ip': '198.51.100.9' }, + ...(method === 'GET' || method === 'HEAD' ? {} : { body: request.postDataBuffer() }), + }), + env, + ctx + ); + await route.fulfill({ status: res.status, headers: Object.fromEntries(res.headers), body: Buffer.from(await res.arrayBuffer()) }); + }); + }); + + async function openPoll() { + const res = await workerFetch('/api/polls', { + method: 'POST', + uid: 'owner-uid', + body: { ballot: { categories: [{ role: 'Standard Speech', label: 'Best Speaker', candidates: ['Alice', 'Bob'] }] } }, + }); + expect(res.ok).toBe(true); + return (await res.json()).id; + } + + test('shows the ballot to a browser with no sign-in, takes one vote per device, and never shows a count', async ({ page }) => { + const id = await openPoll(); + + const response = await page.goto(`${ORIGIN}/vote/${id}`); + expect(response.status()).toBe(200); + await expect(page.getByText('Best Speaker').first()).toBeVisible(); + await expect(page.getByText(/one vote per device/i).first()).toBeVisible(); + + await page.getByRole('radio', { name: 'Bob' }).check(); + await page.getByRole('button', { name: /submit vote/i }).click(); + await expect(page.getByText(/thanks/i).first()).toBeVisible(); + + const results = await (await workerFetch(`/api/polls/${id}/results`, { uid: 'owner-uid' })).json(); + expect(results.counts[0][1]).toBe(1); + expect(results.byPath.link[0][1]).toBe(1); + + // Same browser, same device token: refused, and the count stays put. + await page.reload(); + await page.getByRole('radio', { name: 'Alice' }).check(); + await page.getByRole('button', { name: /submit vote/i }).click(); + await expect(page.getByText(/already voted/i).first()).toBeVisible(); + const after = await (await workerFetch(`/api/polls/${id}/results`, { uid: 'owner-uid' })).json(); + expect(after.counts[0][0] ?? 0).toBe(0); + + await expect(page.getByText(/\b\d+ votes?\b/i)).toHaveCount(0); + }); + + test('says the poll is closed once the owner closes it', async ({ page }) => { + const id = await openPoll(); + expect((await workerFetch(`/api/polls/${id}/close`, { method: 'POST', uid: 'owner-uid' })).ok).toBe(true); + + await page.goto(`${ORIGIN}/vote/${id}`); + await expect(page.getByText(/closed/i).first()).toBeVisible(); + await expect(page.getByRole('button', { name: /submit vote/i })).toHaveCount(0); + }); + + test('is a real 404 for an id nobody minted', async ({ page }) => { + const response = await page.goto(`${ORIGIN}/vote/ZZZZZZZZZZZZZZZZ`); + expect(response.status()).toBe(404); + }); +}); diff --git a/packages/shared/__tests__/clubArchive.test.js b/packages/shared/__tests__/clubArchive.test.js index 2f72025..b21aa6c 100644 --- a/packages/shared/__tests__/clubArchive.test.js +++ b/packages/shared/__tests__/clubArchive.test.js @@ -18,6 +18,7 @@ import { startNewMeeting, subscribeOutbox, } from '../clubArchive.js'; +import * as clubArchive from '../clubArchive.js'; import { CLUB_STORAGE_KEY, resetClubForTests } from '../club.js'; import { resetEntitlementForTests } from '../entitlement.js'; @@ -852,3 +853,83 @@ describe('after a drain that had nothing to do', () => { } }); }); + +/** + * Closed poll results (#80, AC-9) travel the same outbox as speeches: + * `recordPollResults(results, {getToken, fetchImpl, now})` queues them for the + * meeting this device is timing and posts them to + * `/api/club/meetings//polls`, surviving a reload like a speech. + */ +describe('queueing a closed poll', () => { + const closed = (over = {}) => ({ + pollId: 'ABCDEFGHJKMNPQRS', + closedAt: EVENING, + categories: [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', candidates: ['Alice', 'Bob'], counts: [3, 1], winners: ['Alice'] }, + ], + ...over, + }); + + const record = (...args) => { + expect(typeof clubArchive.recordPollResults, 'clubArchive.js exports recordPollResults()').toBe('function'); + return clubArchive.recordPollResults(...args); + }; + + it('queues nothing without a club, or once the club has lapsed', () => { + const fetchImpl = vi.fn(); + expect(record(closed(), { fetchImpl })).toBeNull(); + + joinClub({ entitled: false, plan: 'free', status: 'canceled' }); + expect(record(closed(), { fetchImpl })).toBeNull(); + expect(readOutbox()).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('posts the results to the meeting\'s polls route with the club header', async () => { + joinClub(); + const fetchImpl = vi.fn(async () => ok()); + + record(closed(), { fetchImpl, now: EVENING }); + await drainOutbox({ fetchImpl }); + + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('/api/club/meetings/20260929/polls'); + expect(init.method).toBe('POST'); + expect(init.headers['X-Club']).toBe('club.tok'); + expect(JSON.parse(init.body)).toMatchObject({ + pollId: 'ABCDEFGHJKMNPQRS', + categories: [{ role: 'Table Topics Speech', label: 'Best Table Topics Speaker', winners: ['Alice'] }], + }); + expect(readOutbox()).toEqual([]); + }); + + it('still holds the results after a reload when the network was down, and sends them next time', async () => { + joinClub(); + const offline = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + record(closed(), { fetchImpl: offline }); + await drainOutbox({ fetchImpl: offline }); + + reload(); + + expect(readOutbox()).toHaveLength(1); + const online = vi.fn(async () => ok()); + await drainOutbox({ fetchImpl: online }); + expect(online.mock.calls[0][0]).toBe('/api/club/meetings/20260929/polls'); + expect(readOutbox()).toEqual([]); + }); + + it('sends speeches to the speeches route and the poll to the polls route, in the order they happened', async () => { + joinClub(); + const offline = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + recordSpeech(speech(), { fetchImpl: offline }); + record(closed(), { fetchImpl: offline }); + await drainOutbox({ fetchImpl: offline }); + + const online = vi.fn(async () => ok()); + await drainOutbox({ fetchImpl: online }); + expect(online.mock.calls.map(([url]) => url)).toEqual([ + '/api/club/meetings/20260929/speeches', + '/api/club/meetings/20260929/polls', + ]); + }); +}); diff --git a/packages/shared/__tests__/polls.test.js b/packages/shared/__tests__/polls.test.js new file mode 100644 index 0000000..fe1fcb3 --- /dev/null +++ b/packages/shared/__tests__/polls.test.js @@ -0,0 +1,259 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import { CLUB_STORAGE_KEY, CLUB_PRESETS_STORAGE_KEY, refreshClub, resetClubForTests } from '../club.js'; +import { resetEntitlementForTests } from '../entitlement.js'; + +/** + * Meeting polls (#80): the pure half, in packages/shared/polls.js. + * + * defaultBallot() the three usual categories, three empty slots each + * ballotFromTemplate(template, reports, roleNames) + * this meeting's timed speakers, per category's role + * normalizeTemplate(raw) {categories: [{role, label?, slots}], + * options: {postToChat, showOnStage, expiryHours}} + * winners(counts) every name tied at the top count; none for zero votes + * activePollTemplate() club template (when the club's presets are available), + * else the personal one, else the default + * + * A ballot is `{categories: [{role, label?, candidates: [name, …]}]}`; an empty + * slot is an empty-string candidate. `counts` maps a candidate's name to votes. + * + * Loaded dynamically so a missing module fails these assertions one by one + * rather than the whole file at import. + */ + +const DEFAULT_ROLES = ['Table Topics Speech', 'Standard Speech', 'Table Topics Evaluation']; + +// A variable, so the bundler does not refuse the file before polls.js exists. +const POLLS_MODULE = '../polls.js'; +let polls = {}; +beforeAll(async () => { + polls = await import(/* @vite-ignore */ POLLS_MODULE).catch(() => ({})); +}); + +const fn = (name) => { + expect(typeof polls[name], `polls.js exports ${name}()`).toBe('function'); + return polls[name]; +}; + +const report = (name, role, finishedAt) => ({ + speechId: `${name}-${finishedAt}`, + name, + role, + duration: '1:30', + color: 'green', + comments: '', + disqualified: false, + finishedAt, +}); + +beforeEach(() => { + localStorage.clear(); + resetClubForTests(); + resetEntitlementForTests(); +}); + +describe('defaultBallot', () => { + // AC-2: no template anywhere still gives a club its usual three votes. + it('is Table Topics Speech, Standard Speech and Table Topics Evaluation, three empty slots each', () => { + const ballot = fn('defaultBallot')(); + expect(ballot.categories.map((c) => c.role)).toEqual(DEFAULT_ROLES); + for (const category of ballot.categories) expect(category.candidates).toEqual(['', '', '']); + }); + + it('hands out a fresh ballot each time, so editing one cannot change the next', () => { + const first = fn('defaultBallot')(); + first.categories[0].candidates[0] = 'Edited'; + expect(fn('defaultBallot')().categories[0].candidates[0]).toBe(''); + }); +}); + +describe('normalizeTemplate', () => { + it('keeps the categories and options it knows, and nothing else', () => { + const template = fn('normalizeTemplate')({ + categories: [ + { role: 'Standard Speech', label: 'Best Speaker', slots: 2, extra: 'dropped' }, + { role: 'Contest Speech', slots: 4 }, + ], + options: { postToChat: true, showOnStage: false, expiryHours: 12, junk: 1 }, + junk: true, + }); + + expect(template.categories).toHaveLength(2); + expect(template.categories[0]).toMatchObject({ role: 'Standard Speech', label: 'Best Speaker', slots: 2 }); + expect(template.categories[0]).not.toHaveProperty('extra'); + expect(template.categories[1]).toMatchObject({ role: 'Contest Speech', slots: 4 }); + expect(template.options).toMatchObject({ postToChat: true, showOnStage: false, expiryHours: 12 }); + expect(template.options).not.toHaveProperty('junk'); + expect(template).not.toHaveProperty('junk'); + }); + + it('drops a category with no role', () => { + const template = fn('normalizeTemplate')({ categories: [{ label: 'Nobody', slots: 3 }, { role: 'Standard Speech', slots: 3 }] }); + expect(template.categories.map((c) => c.role)).toEqual(['Standard Speech']); + }); +}); + +describe('ballotFromTemplate', () => { + const template = { + categories: [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', slots: 3 }, + { role: 'Standard Speech', label: 'Best Speaker', slots: 3 }, + { role: 'Table Topics Evaluation', slots: 2 }, + ], + options: { postToChat: true, showOnStage: false, expiryHours: 24 }, + }; + const roleNames = [...DEFAULT_ROLES, 'Ice Breaker']; + + // AC-5: the timer already knows who spoke in each role. + it('makes each category\'s candidates the distinct names timed in its role, in timing order', () => { + const reports = [ + report('Bob', 'Table Topics Speech', 1_000), + report('Alice', 'Standard Speech', 2_000), + report('Carol', 'Table Topics Speech', 3_000), + report('Bob', 'Table Topics Speech', 4_000), + report('Ann', 'Ice Breaker', 5_000), + report('Dan', 'Table Topics Speech', 6_000), + ]; + + const ballot = fn('ballotFromTemplate')(template, reports, roleNames); + + expect(ballot.categories[0]).toMatchObject({ role: 'Table Topics Speech', label: 'Best Table Topics Speaker' }); + expect(ballot.categories[0].candidates).toEqual(['Bob', 'Carol', 'Dan']); + expect(ballot.categories[1].candidates).toEqual(['Alice']); + }); + + it('gives a category with nobody timed its empty slots', () => { + const ballot = fn('ballotFromTemplate')(template, [report('Bob', 'Table Topics Speech', 1_000)], roleNames); + expect(ballot.categories[1].candidates).toEqual(['', '', '']); + expect(ballot.categories[2].candidates).toEqual(['', '']); + }); + + it('skips a speech timed with no name', () => { + const ballot = fn('ballotFromTemplate')( + template, + [report('', 'Table Topics Speech', 1_000), report('Bob', 'Table Topics Speech', 2_000)], + roleNames + ); + expect(ballot.categories[0].candidates).toEqual(['Bob']); + }); +}); + +describe('winners', () => { + // AC-8 and Q8. + it('is the single top name when there is one', () => { + expect(fn('winners')({ Alice: 4, Bob: 2, Carol: 1 })).toEqual(['Alice']); + }); + + it('is every name tied at the top count', () => { + expect(fn('winners')({ Alice: 3, Bob: 3, Carol: 1 })).toEqual(['Alice', 'Bob']); + }); + + it('is nobody when the category got no votes', () => { + expect(fn('winners')({ Alice: 0, Bob: 0 })).toEqual([]); + expect(fn('winners')({})).toEqual([]); + }); +}); + +describe('activePollTemplate', () => { + const PERSONAL = { + categories: [{ role: 'Ice Breaker', label: 'Best Icebreaker', slots: 2 }], + options: { postToChat: false, showOnStage: true, expiryHours: 6 }, + }; + const CLUB = { + categories: [{ role: 'Contest Speech', label: 'Best Contestant', slots: 4 }], + options: { postToChat: true, showOnStage: false, expiryHours: 12 }, + }; + + function joinClub({ entitled = true } = {}) { + localStorage.setItem( + CLUB_STORAGE_KEY, + JSON.stringify({ + clubToken: 'tok.sig', + ver: 2, + lastRefreshAt: Date.now(), + club: { id: 'club-1', name: 'Downtown Speakers' }, + plan: entitled ? 'pro' : 'free', + entitled, + status: entitled ? 'active' : 'canceled', + source: 'club', + }) + ); + localStorage.setItem(CLUB_PRESETS_STORAGE_KEY, JSON.stringify({ rules: { 'Contest Speech': { green: 1, yellow: 2, red: 3 } }, order: [], hiddenBuiltins: [] })); + localStorage.setItem('toastmaster_club_poll_template', JSON.stringify(CLUB)); + resetClubForTests(); + } + + it('is the default with no template anywhere', () => { + const template = fn('activePollTemplate')(); + expect(template.categories.map((c) => c.role)).toEqual(DEFAULT_ROLES); + expect(template.categories.map((c) => c.slots)).toEqual([3, 3, 3]); + }); + + it('is the personal template once one is saved', () => { + localStorage.setItem('toastmaster_poll_template', JSON.stringify(PERSONAL)); + expect(fn('activePollTemplate')()).toMatchObject(PERSONAL); + }); + + it('is the club\'s template on a club device, ahead of the personal one', () => { + localStorage.setItem('toastmaster_poll_template', JSON.stringify(PERSONAL)); + joinClub(); + expect(fn('activePollTemplate')()).toMatchObject(CLUB); + }); + + // AC-10: a lapsed club's template stays stored but is not used. + it('falls back to the personal template when the club has lapsed', () => { + localStorage.setItem('toastmaster_poll_template', JSON.stringify(PERSONAL)); + joinClub({ entitled: false }); + expect(fn('activePollTemplate')()).toMatchObject(PERSONAL); + expect(localStorage.getItem('toastmaster_club_poll_template')).not.toBeNull(); + }); +}); + +describe('the club template reaches a member on refresh', () => { + const CLUB_TEMPLATE = { + categories: [{ role: 'Contest Speech', label: 'Best Contestant', slots: 4 }], + options: { postToChat: true, showOnStage: false, expiryHours: 12 }, + }; + const state = (over = {}) => ({ + ver: 1, + club: { id: 'club-1', name: 'Downtown Speakers' }, + kit: null, + presets: { rules: { 'Contest Speech': { green: 1, yellow: 2, red: 3 } }, order: [], hiddenBuiltins: [] }, + badge: null, + cards: null, + pollTemplate: null, + timezone: null, + role: 'member', + plan: 'pro', + entitled: true, + status: 'active', + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + source: 'club', + ...over, + }); + const respondWith = (body) => vi.fn(async () => ({ ok: true, status: 200, json: async () => body })); + + beforeEach(() => { + localStorage.setItem(CLUB_STORAGE_KEY, JSON.stringify({ clubToken: 'tok.sig', lastRefreshAt: 0, ...state() })); + resetClubForTests(); + }); + + it('saves the published template when ver moves', async () => { + await refreshClub({ fetchImpl: respondWith(state({ ver: 2, pollTemplate: CLUB_TEMPLATE })), force: true }); + + expect(JSON.parse(localStorage.getItem('toastmaster_club_poll_template'))).toMatchObject(CLUB_TEMPLATE); + expect(fn('activePollTemplate')()).toMatchObject(CLUB_TEMPLATE); + }); + + it('leaves the stored copy alone on a refresh where ver did not move', async () => { + await refreshClub({ fetchImpl: respondWith(state({ ver: 2, pollTemplate: CLUB_TEMPLATE })), force: true }); + const stored = localStorage.getItem('toastmaster_club_poll_template'); + + await refreshClub({ + fetchImpl: respondWith(state({ ver: 2, pollTemplate: { ...CLUB_TEMPLATE, options: { ...CLUB_TEMPLATE.options, expiryHours: 1 } } })), + force: true, + }); + expect(localStorage.getItem('toastmaster_club_poll_template')).toBe(stored); + }); +}); diff --git a/packages/shared/__tests__/profileMerge.test.js b/packages/shared/__tests__/profileMerge.test.js index b7eb92f..34e7054 100644 --- a/packages/shared/__tests__/profileMerge.test.js +++ b/packages/shared/__tests__/profileMerge.test.js @@ -172,3 +172,19 @@ describe('fieldsToApplyLocally', () => { expect(fieldsToApplyLocally(null, remote)).toEqual({ toastmaster_agenda: entry('a', 1) }); }); }); + +// Meeting polls (#80, AC-11): a Pro user without a club keeps their ballot +// setup on every device. The meeting's own draft and results stay local. +describe('the personal poll template', () => { + it('syncs through the profile', () => { + expect(SYNCED_KEYS).toContain('toastmaster_poll_template'); + const merged = normalizeProfile(profile({ toastmaster_poll_template: entry({ categories: [], options: {} }, 100) })); + expect(merged.fields).toHaveProperty('toastmaster_poll_template'); + }); + + it('keeps one meeting\'s draft, results and the club\'s copy out of it', () => { + expect(SYNCED_KEYS).not.toContain('toastmaster_poll_draft'); + expect(SYNCED_KEYS).not.toContain('toastmaster_poll_results'); + expect(SYNCED_KEYS).not.toContain('toastmaster_club_poll_template'); + }); +}); diff --git a/packages/shared/__tests__/reportImage.test.js b/packages/shared/__tests__/reportImage.test.js index f725d32..2f42f3f 100644 --- a/packages/shared/__tests__/reportImage.test.js +++ b/packages/shared/__tests__/reportImage.test.js @@ -345,3 +345,60 @@ describe('filenames and summaries', () => { expect(readableReportDate('not-a-date')).toBeNull(); }); }); + +/** + * Poll winners on the picture (#80, AC-17, Q6): a "Winners" block under the + * speeches, one row per category (its label, else its role), tied names + * joined, "No votes" for a category nobody voted in. Closed results ride in as + * `polls` beside `speeches`, in the shape the club archive stores them. + */ +describe('the Winners block', () => { + const POLLS = [ + { + pollId: 'ABCDEFGHJKMNPQRS', + closedAt: 1, + categories: [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', candidates: ['Ann', 'Bo'], counts: [3, 3], winners: ['Ann', 'Bo'] }, + { role: 'Standard Speech', label: 'Best Speaker', candidates: ['Cy'], counts: [4], winners: ['Cy'] }, + { role: 'Table Topics Evaluation', candidates: ['Di'], counts: [0], winners: [] }, + ], + }, + ]; + + it('draws a Winners heading and one row per category, ties joined and "No votes" for an empty one', async () => { + const { ops, createCanvas } = stubCanvas(); + + await renderReportPng({ kit: KIT, meeting: MEETING, speeches: [speech()], polls: POLLS }, { createCanvas }); + const text = textOf(ops); + const all = text.join('\n'); + + expect(text.some((t) => /winners/i.test(t))).toBe(true); + expect(all).toContain('Best Table Topics Speaker'); + expect(text.some((t) => t.includes('Ann') && t.includes('Bo'))).toBe(true); + expect(all).toContain('Best Speaker'); + expect(all).toContain('Cy'); + expect(all).toContain('Table Topics Evaluation'); + expect(all).toContain('No votes'); + }); + + it('grows the full picture to make room for it', async () => { + const { sizes, createCanvas } = stubCanvas(); + + await renderReportPng({ kit: KIT, meeting: MEETING, speeches: [speech()], polls: POLLS }, { createCanvas }); + + expect(sizes[0].height).toBeGreaterThan(reportImageHeight(1)); + }); + + it('draws exactly what it drew before when there are no poll results', async () => { + const before = stubCanvas(); + const withEmpty = stubCanvas(); + + await renderReportPng({ kit: KIT, meeting: MEETING, speeches: manySpeeches(3) }, { createCanvas: before.createCanvas }); + await renderReportPng({ kit: KIT, meeting: MEETING, speeches: manySpeeches(3), polls: [] }, { createCanvas: withEmpty.createCanvas }); + + expect(withEmpty.sizes).toEqual(before.sizes); + expect(before.sizes[0].height).toBe(reportImageHeight(3)); + expect(withEmpty.ops).toEqual(before.ops); + expect(textOf(before.ops).some((t) => /winners/i.test(t))).toBe(false); + }); +}); diff --git a/packages/shared/club.js b/packages/shared/club.js index e81a2cb..df2a6f0 100644 --- a/packages/shared/club.js +++ b/packages/shared/club.js @@ -28,6 +28,8 @@ export const CLUB_PRESETS_STORAGE_KEY = 'toastmaster_club_presets'; export const PRESET_SOURCE_STORAGE_KEY = 'toastmaster_preset_source'; /** The club's published card set, as the device last received it: hashes, not pixels. */ export const CLUB_CARDS_STORAGE_KEY = 'toastmaster_club_cards'; +/** The club's published poll template (polls.js), as the device last received it. */ +export const CLUB_POLL_TEMPLATE_STORAGE_KEY = 'toastmaster_club_poll_template'; /** * Which cards this device shows: 'club' or 'own'. Unset means derived, as the * preset switch is: a device that never chose its own cards shows the club's. @@ -183,6 +185,16 @@ export function saveClubPresets(presets) { } } +/** @param {Object|null} template - null removes the key */ +export function saveClubPollTemplate(template) { + try { + if (template?.categories) localStorage.setItem(CLUB_POLL_TEMPLATE_STORAGE_KEY, JSON.stringify(template)); + else localStorage.removeItem(CLUB_POLL_TEMPLATE_STORAGE_KEY); + } catch { + // Private mode. The club still works this page load; it just re-fetches. + } +} + // Parsed once and kept: the card resolver reads this on every status change, // which sits inside the 25 ms card-switch budget. let cardsCache; @@ -770,6 +782,7 @@ export async function activateClub(code, { getToken, fetchImpl } = {}) { // set lands the same way, behind its own switch in cardImages.js. saveClubPresets(body.presets ?? null); saveClubCards(body.cards ?? null); + saveClubPollTemplate(body.pollTemplate ?? null); const entry = store(toCacheEntry(body, { clubToken: body.clubToken, lastRefreshAt: Date.now() })); applyPlan(clubEntitlementOf(entry)); warmLogoQuietly(); @@ -832,6 +845,7 @@ export async function createClub(details = {}, { getToken, fetchImpl } = {}) { // resolves, so nobody is ever shown a code and asked to type it back in. saveClubPresets(body.presets ?? null); saveClubCards(body.cards ?? null); + saveClubPollTemplate(body.pollTemplate ?? null); const entry = store(toCacheEntry(body, { clubToken: body.clubToken, lastRefreshAt: Date.now() })); applyPlan(clubEntitlementOf(entry)); warmLogoQuietly(); @@ -913,6 +927,9 @@ export async function refreshClub({ getToken, fetchImpl, force = false, now = Da // none at all, so a device that last refreshed on a bundle that did not know // about card sets still receives the one already published. if (verMoved || (!loadClubCards() && body.cards)) saveClubCards(body.cards ?? null); + // The poll template too: a member's one-meeting edits live in the poll draft, + // so only a publish replaces this copy. + if (verMoved) saveClubPollTemplate(body.pollTemplate ?? null); const entry = store(toCacheEntry(body, { clubToken: body.clubToken ?? club.clubToken, lastRefreshAt: now })); applyPlan(clubEntitlementOf(entry)); @@ -963,6 +980,7 @@ export function leaveClub({ fetchImpl } = {}) { // touched, so its own choice is simply what shows again. saveClubCards(null); writeCardSource(null); + saveClubPollTemplate(null); // The badge placement goes with the club: it is a position on the club's // badge, and there is no badge to place once the club is gone. clearClubBadgeOverride(); @@ -1016,6 +1034,21 @@ export function rememberPublishedCards(ver, cards) { return store({ ...club, ver: typeof ver === 'number' ? ver : club.ver }); } +/** + * Take the answer to a poll template publish, for the same reason + * `rememberPublishedPresets` exists. + * + * @param {number} ver + * @param {Object|null} template + * @returns {Object|null} the club as it now stands + */ +export function rememberPublishedPollTemplate(ver, template) { + const club = loadClub(); + if (!club) return null; + saveClubPollTemplate(template ?? null); + return store({ ...club, ver: typeof ver === 'number' ? ver : club.ver }); +} + /** Test seam: drop the in-memory copy so the next read comes from storage. */ export function resetClubForTests() { cached = undefined; diff --git a/packages/shared/clubArchive.js b/packages/shared/clubArchive.js index 5e67b9a..2f9073e 100644 --- a/packages/shared/clubArchive.js +++ b/packages/shared/clubArchive.js @@ -309,6 +309,40 @@ export function recordSpeech(speech, { getToken, fetchImpl, now = Date.now() } = return entry; } +/** + * Queue a closed poll's results for the meeting this device is timing. + * + * The same outbox as speeches, so the results survive a reload and go out in + * the order things happened. The Worker keys them by `pollId`, so a retry + * rewrites the same record. Same rules as `recordSpeech`: no club, or a lapsed + * one, queues nothing. + * + * @param {{pollId: string, closedAt?: number, categories: Array}} results + * @param {{getToken?: () => string|null, fetchImpl?: typeof fetch, now?: number}} [options] + * @returns {Object|null} the queued entry, or null when there was no club + */ +export function recordPollResults(results, { getToken, fetchImpl, now = Date.now() } = {}) { + const club = loadClub(); + if (!club?.clubToken || !club.entitled || !results?.pollId) return null; + + const entry = { + kind: 'poll', + pollId: String(results.pollId), + meetingId: deriveMeetingId({ now, timezone: club.timezone ?? null }), + closedAt: typeof results.closedAt === 'number' ? results.closedAt : now, + categories: Array.isArray(results.categories) ? results.categories : [], + }; + + const queued = [...readOutbox(), entry]; + writeOutbox(queued.length > MAX_OUTBOX_ENTRIES ? queued.slice(-MAX_OUTBOX_ENTRIES) : queued); + notify(); + drainOutbox({ getToken, fetchImpl }).catch(() => {}); + return entry; +} + +/** What identifies an outbox entry: a speech by its id, a poll by its. */ +const entryKey = (entry) => (entry.kind === 'poll' ? `poll:${entry.pollId}` : entry.speechId); + // One request in flight at a time, the same discipline the overlay queue and // the profile push already use. Concurrent callers share the running drain. let draining = null; @@ -349,7 +383,9 @@ function withDeadline(promise, ms, controller) { async function uploadOne(entry, { getToken, fetchImpl }) { const token = getToken?.(); const controller = typeof AbortController === 'function' ? new AbortController() : null; - const request = (fetchImpl ?? fetch)(`${MEETINGS_ENDPOINT}/${encodeURIComponent(entry.meetingId)}/speeches`, { + const isPoll = entry.kind === 'poll'; + const route = isPoll ? 'polls' : 'speeches'; + const request = (fetchImpl ?? fetch)(`${MEETINGS_ENDPOINT}/${encodeURIComponent(entry.meetingId)}/${route}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -359,7 +395,7 @@ async function uploadOne(entry, { getToken, fetchImpl }) { credentials: 'same-origin', cache: 'no-store', ...(controller ? { signal: controller.signal } : {}), - body: JSON.stringify({ + body: JSON.stringify(isPoll ? { pollId: entry.pollId, closedAt: entry.closedAt, categories: entry.categories } : { speechId: entry.speechId, name: entry.name, role: entry.role, @@ -496,7 +532,7 @@ export function drainOutbox({ getToken, fetchImpl } = {}) { } // Anything else — accepted, or refused in a way a retry cannot fix — // means this entry is done travelling. - const remaining = readOutbox().filter((queued) => queued.speechId !== entry.speechId); + const remaining = readOutbox().filter((queued) => entryKey(queued) !== entryKey(entry)); writeOutbox(remaining); notify(); if (response.ok) sent += 1; @@ -723,7 +759,7 @@ function isRepeatShare(localIds, last) { * * @param {{title?: string|null, speeches?: Array, meetingId?: string, * getToken?: () => string|null, fetchImpl?: typeof fetch, now?: number, - * createCanvas?: Function}} [options] + * createCanvas?: Function, polls?: Array}} [options] * @returns {Promise<{meetingId: string, date: string|null, title: string|null, * speeches: number, overtime: number, blob: Blob|null, filename: string, * url: string|null, token: string|null, error: string|null}>} @@ -736,6 +772,7 @@ export async function endMeetingAndShare({ fetchImpl, now = Date.now(), createCanvas, + polls = [], } = {}) { const club = loadClub(); const kit = clubKit(); @@ -785,7 +822,9 @@ export async function endMeetingAndShare({ result.speeches = shown.length; result.overtime = shown.filter(isOvertime).length; - const report = { club, kit, meeting: { meetingId: id, date, title: cleanTitle }, speeches: shown }; + // This device's closed polls draw the Winners block; the /r/ page reads the + // same results from the archive. + const report = { club, kit, meeting: { meetingId: id, date, title: cleanTitle }, speeches: shown, polls }; const draw = { logo: getClubLogoImage(), ...(createCanvas ? { createCanvas } : {}) }; let previewPng = null; try { diff --git a/packages/shared/index.js b/packages/shared/index.js index eacd07d..a4b95d7 100644 --- a/packages/shared/index.js +++ b/packages/shared/index.js @@ -20,3 +20,4 @@ export * from './clubCards.js'; export * from './clubArchive.js'; export * from './reportImage.js'; export * from './timerSounds.js'; +export * from './polls.js'; diff --git a/packages/shared/polls.js b/packages/shared/polls.js new file mode 100644 index 0000000..bff956a --- /dev/null +++ b/packages/shared/polls.js @@ -0,0 +1,267 @@ +import { notifyLocalWrite } from './storageEvents.js'; +import { CLUB_POLL_TEMPLATE_STORAGE_KEY, loadClub, clubHeaders, rememberPublishedPollTemplate } from './club.js'; +import { clubPresetsAvailable } from './clubPresets.js'; + +/** + * Meeting polls (#80): the ballot for Best Table Topics Speaker, Best Speaker + * and Best Evaluator, built from the speakers this meeting already timed. + * + * A template is the setup that outlives a meeting: `{categories: [{role, + * label?, slots}], options: {postToChat, showOnStage, expiryHours}}`. A ballot + * is one meeting's poll: `{categories: [{role, label?, candidates}]}`, where an + * empty slot is an empty-string candidate. The Worker holds the votes; this + * module only shapes what goes in and reads what comes out. + * + * Where a template comes from, first match wins: the club's (when the club's + * presets are available on this device, so a lapsed club falls through), the + * personal one (synced, see profileMerge.js), the default. Editing the ballot + * for one meeting writes only the draft, never a template. + */ + +export const POLL_TEMPLATE_STORAGE_KEY = 'toastmaster_poll_template'; +/** This meeting's edited ballot. Local, cleared with the reports. */ +export const POLL_DRAFT_STORAGE_KEY = 'toastmaster_poll_draft'; +/** Closed polls of this meeting. Local, cleared with the reports. */ +export const POLL_RESULTS_STORAGE_KEY = 'toastmaster_poll_results'; + +export const DEFAULT_POLL_ROLES = ['Table Topics Speech', 'Standard Speech', 'Table Topics Evaluation']; +export const DEFAULT_POLL_SLOTS = 3; +export const DEFAULT_POLL_OPTIONS = Object.freeze({ postToChat: true, showOnStage: false, expiryHours: 24 }); +const MAX_SLOTS = 20; + +/** The template a club with nothing saved gets: the three usual votes. */ +export function defaultPollTemplate() { + return { + categories: DEFAULT_POLL_ROLES.map((role) => ({ role, slots: DEFAULT_POLL_SLOTS })), + options: { ...DEFAULT_POLL_OPTIONS }, + }; +} + +/** A fresh default ballot, three empty slots per category. */ +export function defaultBallot() { + return ballotFromTemplate(defaultPollTemplate(), [], DEFAULT_POLL_ROLES); +} + +const clampSlots = (value) => { + const n = Math.round(Number(value)); + return Number.isFinite(n) ? Math.min(MAX_SLOTS, Math.max(1, n)) : DEFAULT_POLL_SLOTS; +}; + +/** + * Keep only what a template is made of. A category with no role is dropped. + * + * @param {Object} raw + * @returns {{categories: Array<{role: string, label?: string, slots: number}>, + * options: {postToChat: boolean, showOnStage: boolean, expiryHours: number}}} + */ +export function normalizeTemplate(raw) { + const categories = (Array.isArray(raw?.categories) ? raw.categories : []) + .filter((c) => typeof c?.role === 'string' && c.role.trim()) + .map((c) => ({ + role: c.role.trim(), + ...(typeof c.label === 'string' && c.label.trim() ? { label: c.label.trim() } : {}), + slots: clampSlots(c.slots), + })); + const options = raw?.options ?? {}; + const hours = Number(options.expiryHours); + return { + categories, + options: { + postToChat: typeof options.postToChat === 'boolean' ? options.postToChat : DEFAULT_POLL_OPTIONS.postToChat, + showOnStage: typeof options.showOnStage === 'boolean' ? options.showOnStage : DEFAULT_POLL_OPTIONS.showOnStage, + expiryHours: Number.isFinite(hours) && hours > 0 ? Math.min(24, hours) : DEFAULT_POLL_OPTIONS.expiryHours, + }, + }; +} + +/** + * This meeting's ballot: each category's candidates are the distinct names + * timed in its role, in timing order; a category nobody was timed in gets its + * empty slots. + * + * @param {Object} template + * @param {Array<{name: string, role: string, finishedAt?: number}>} reports + * @param {string[]} [roleNames] - roles the timer knows; a category in any other role still keeps its slots + */ +export function ballotFromTemplate(template, reports = [], roleNames = []) { + const known = new Set(roleNames); + const timed = [...(reports ?? [])].sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0)); + return { + categories: normalizeTemplate(template).categories.map(({ role, label, slots }) => { + const names = []; + if (known.size === 0 || known.has(role)) { + for (const r of timed) { + const name = typeof r?.name === 'string' ? r.name.trim() : ''; + if (r?.role === role && name && !names.includes(name)) names.push(name); + } + } + return { role, ...(label ? { label } : {}), candidates: names.length ? names : Array(slots).fill('') }; + }), + }; +} + +/** + * Every name tied at the top count, in the order given; nobody when nobody voted. + * + * @param {Object} counts - candidate name → votes + * @returns {string[]} + */ +export function winners(counts) { + const entries = Object.entries(counts ?? {}).filter(([, n]) => Number(n) > 0); + if (!entries.length) return []; + const top = Math.max(...entries.map(([, n]) => Number(n))); + return entries.filter(([, n]) => Number(n) === top).map(([name]) => name); +} + +/** What a category is called: its label, else its role. */ +export const categoryTitle = (category) => category?.label || category?.role || ''; + +function readJson(key) { + try { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function writeJson(key, value, { synced = false } = {}) { + try { + if (value === null) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify(value)); + if (synced) notifyLocalWrite(key); + } catch { + // Private mode: the poll still runs this page load. + } +} + +const usable = (template) => (template && normalizeTemplate(template).categories.length ? normalizeTemplate(template) : null); + +/** The personal template, or null. */ +export const loadPollTemplate = () => usable(readJson(POLL_TEMPLATE_STORAGE_KEY)); + +/** Save the personal template through the synced store. */ +export function savePollTemplate(template) { + writeJson(POLL_TEMPLATE_STORAGE_KEY, normalizeTemplate(template), { synced: true }); +} + +/** The club's template as this device last received it, or null. */ +export const loadClubPollTemplate = () => usable(readJson(CLUB_POLL_TEMPLATE_STORAGE_KEY)); + +/** The template this device sets polls up from right now. */ +export function activePollTemplate() { + return (clubPresetsAvailable() && loadClubPollTemplate()) || loadPollTemplate() || defaultPollTemplate(); +} + +/** + * Publish a template to the club. Refused server-side for anyone but an admin + * or editor, as presets are. + * + * @param {Object} template + * @param {{getToken?: () => string|null, fetchImpl?: typeof fetch}} [options] + * @returns {Promise<{ok: true, ver: number}|{ok: false, error: string}>} never rejects + */ +export async function publishPollTemplate(template, { getToken, fetchImpl } = {}) { + if (!loadClub()?.clubToken) return { ok: false, error: 'no_club' }; + const normalized = normalizeTemplate(template); + let response; + try { + const token = getToken?.(); + response = await (fetchImpl ?? fetch)('/api/club/poll-template', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...clubHeaders(), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + credentials: 'same-origin', + cache: 'no-store', + body: JSON.stringify(normalized), + }); + } catch { + return { ok: false, error: 'network' }; + } + const body = await response.json().catch(() => null); + if (!response.ok) return { ok: false, error: body?.error || (response.status === 403 ? 'forbidden' : 'publish_failed') }; + rememberPublishedPollTemplate(body?.ver, body?.pollTemplate?.categories ? body.pollTemplate : normalized); + return { ok: true, ver: body?.ver }; +} + +/** This meeting's edited ballot, or null when it has not been touched. */ +export function loadPollDraft() { + const draft = readJson(POLL_DRAFT_STORAGE_KEY); + return Array.isArray(draft?.categories) ? draft : null; +} + +export const savePollDraft = (draft) => writeJson(POLL_DRAFT_STORAGE_KEY, draft ?? null); + +/** Closed polls of this meeting, oldest first. */ +export function loadPollResults() { + const results = readJson(POLL_RESULTS_STORAGE_KEY); + return Array.isArray(results) ? results : []; +} + +/** Add or replace one closed poll, by pollId. */ +export function savePollResult(result) { + const others = loadPollResults().filter((r) => r.pollId !== result.pollId); + writeJson(POLL_RESULTS_STORAGE_KEY, [...others, result]); +} + +/** Drop this meeting's draft and results; Clear on the Report tab calls it with the reports. */ +export function clearPollMeeting() { + writeJson(POLL_DRAFT_STORAGE_KEY, null); + writeJson(POLL_RESULTS_STORAGE_KEY, null); +} + +/** + * Turn the Worker's answer to Close into what this device keeps: per category, + * its names, counts in candidate order, the path split and the winners. + * + * @param {string} pollId + * @param {{categories: Array}} ballot - the ballot the poll was opened with + * @param {{counts: number[][], byPath?: Object, winners?: string[][]}} closed + * @param {number} [closedAt] + */ +export function closedPollResult(pollId, ballot, closed, closedAt = Date.now()) { + return { + pollId, + closedAt, + categories: (ballot?.categories ?? []).map((category, i) => { + const candidates = category.candidates ?? []; + const counts = candidates.map((_, j) => Number(closed?.counts?.[i]?.[j] ?? 0)); + const byPath = {}; + for (const path of ['zoom', 'guest', 'link']) { + byPath[path] = candidates.map((_, j) => Number(closed?.byPath?.[path]?.[i]?.[j] ?? 0)); + } + const byName = {}; + candidates.forEach((name, j) => { + byName[name] = (byName[name] ?? 0) + counts[j]; + }); + return { + role: category.role, + ...(category.label ? { label: category.label } : {}), + candidates, + counts, + byPath, + winners: Array.isArray(closed?.winners?.[i]) ? closed.winners[i] : winners(byName), + }; + }), + }; +} + +// What "Show on Stage" put on this device's stage: `[{role, label?, winners}]` +// or null. In memory only, like the stage itself. +let stageWinners = null; +const stageListeners = new Set(); + +export const getStageWinners = () => stageWinners; + +export function setStageWinners(categories) { + stageWinners = Array.isArray(categories) && categories.length ? categories : null; + for (const listener of stageListeners) listener(); +} + +export function subscribeStageWinners(listener) { + stageListeners.add(listener); + return () => stageListeners.delete(listener); +} diff --git a/packages/shared/profileMerge.js b/packages/shared/profileMerge.js index 12303e9..6ae0db8 100644 --- a/packages/shared/profileMerge.js +++ b/packages/shared/profileMerge.js @@ -40,6 +40,9 @@ export const SYNCED_KEYS = Object.freeze([ // Which uploaded picture belongs to which card slot, so another device knows // what artwork to fetch. The pictures themselves live in R2, not here. 'toastmaster_card_asset_hashes', + // The personal ballot setup for meeting polls (polls.js). One meeting's draft + // and its results stay local, and a club device reads the club's copy. + 'toastmaster_poll_template', ]); const SYNCED = new Set(SYNCED_KEYS); diff --git a/packages/shared/reportImage.js b/packages/shared/reportImage.js index 04f3b18..8652c28 100644 --- a/packages/shared/reportImage.js +++ b/packages/shared/reportImage.js @@ -257,6 +257,32 @@ function drawRow(ctx, speech, index, top) { } } +/** Every category of every closed poll, in order: one Winners row each. */ +function winnerRows(polls) { + return (Array.isArray(polls) ? polls : []).flatMap((poll) => (Array.isArray(poll?.categories) ? poll.categories : [])); +} + +/** How much taller the full picture gets for its Winners block: a heading plus a row per category. */ +const winnersBlockHeight = (rows) => (rows.length ? REPORT_ROW_HEIGHT * (rows.length + 1) : 0); + +function drawWinners(ctx, rows, top) { + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = INK; + ctx.font = `bold 20px ${FONT_STACK}`; + ctx.fillText('Winners', PADDING, top + REPORT_ROW_HEIGHT / 2); + rows.forEach((category, index) => { + const middle = top + REPORT_ROW_HEIGHT * (index + 1) + REPORT_ROW_HEIGHT / 2; + ctx.fillStyle = MUTED; + ctx.font = `19px ${FONT_STACK}`; + ctx.fillText(ellipsizeText(ctx, String(category.label || category.role || ''), 400), PADDING, middle); + const names = Array.isArray(category.winners) && category.winners.length ? category.winners.join(', ') : 'No votes'; + ctx.fillStyle = INK; + ctx.font = `bold 19px ${FONT_STACK}`; + ctx.fillText(ellipsizeText(ctx, names, REPORT_IMAGE_WIDTH - PADDING * 2 - 440), PADDING + 440, middle); + }); +} + function drawFooter(ctx, height, { hidden = 0 } = {}) { const top = height - REPORT_FOOTER_HEIGHT; ctx.fillStyle = RULE; @@ -279,14 +305,17 @@ function drawFooter(ctx, height, { hidden = 0 } = {}) { * variant 'full' 1200 × (180 + 44·rows) every speech — Copy image * variant 'preview' 1200 × 630 six rows + "…and N more" — the OG image * + * Closed poll results (`polls`, as the club archive stores them) add a + * Winners block under the speeches on the full picture. + * * @param {{club?: Object|null, kit?: Object|null, meeting?: Object|null, - * speeches?: Array}} report + * speeches?: Array, polls?: Array}} report * @param {{variant?: 'full'|'preview', createCanvas?: Function, * logo?: CanvasImageSource|null, label?: string}} [options] * @returns {Promise} null when the device has no PNG encoder */ export async function renderReportPng( - { club = null, kit = null, meeting = null, speeches = [] } = {}, + { club = null, kit = null, meeting = null, speeches = [], polls = [] } = {}, { variant = 'full', createCanvas = defaultCreateCanvas, logo = null, label = 'Timing report' } = {} ) { const rows = Array.isArray(speeches) ? speeches : []; @@ -294,7 +323,8 @@ export async function renderReportPng( const shown = preview ? rows.slice(0, PREVIEW_MAX_ROWS) : rows; const hidden = rows.length - shown.length; - const height = preview ? PREVIEW_IMAGE_HEIGHT : reportImageHeight(shown.length); + const winners = preview ? [] : winnerRows(polls); + const height = preview ? PREVIEW_IMAGE_HEIGHT : reportImageHeight(shown.length) + winnersBlockHeight(winners); const canvas = createCanvas(REPORT_IMAGE_WIDTH, height); // A stub canvas in a test gets its size from the constructor; a real one // needs it assigned, and assigning it also clears the bitmap. @@ -322,6 +352,8 @@ export async function renderReportPng( drawRow(ctx, speech ?? {}, index, REPORT_HEADER_HEIGHT + index * REPORT_ROW_HEIGHT); }); + if (winners.length) drawWinners(ctx, winners, REPORT_HEADER_HEIGHT + shown.length * REPORT_ROW_HEIGHT); + drawFooter(ctx, height, { hidden: preview ? hidden : 0 }); return canvasToBlob(canvas); diff --git a/packages/shared/storage.js b/packages/shared/storage.js index 9ee08ea..ca76b4f 100644 --- a/packages/shared/storage.js +++ b/packages/shared/storage.js @@ -98,6 +98,9 @@ export function loadReports() { export function clearReports() { try { forget(STORAGE_KEYS.REPORTS); + // A meeting's poll draft and closed results (polls.js) go with its reports. + localStorage.removeItem('toastmaster_poll_draft'); + localStorage.removeItem('toastmaster_poll_results'); } catch (error) { console.error('Failed to clear reports:', error); } diff --git a/worker/auth.js b/worker/auth.js index edf217f..b150342 100644 --- a/worker/auth.js +++ b/worker/auth.js @@ -405,10 +405,14 @@ export async function handleAuthStart(request, url, env, { now = Date.now(), ctx * @param {Object} env - ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET * @param {string} code * @param {string} redirectUri - * @param {{codeVerifier?: string, fetchImpl?: typeof fetch}} [options] - * @returns {Promise<{ok: boolean, accessToken: string|null, status: number, scope: string|null}>} + * The refresh token is dropped unless `returnRefreshToken` asks for it: only + * the native Zoom poll (polls-zoom.js) keeps one, briefly and encrypted. + * + * @param {{codeVerifier?: string, returnRefreshToken?: boolean, fetchImpl?: typeof fetch}} [options] + * @returns {Promise<{ok: boolean, accessToken: string|null, status: number, scope: string|null, + * refreshToken?: string|null}>} */ -export async function exchangeZoomCode(env, code, redirectUri, { codeVerifier, fetchImpl = fetch } = {}) { +export async function exchangeZoomCode(env, code, redirectUri, { codeVerifier, returnRefreshToken = false, fetchImpl = fetch } = {}) { const basic = Buffer.from(`${env.ZOOM_CLIENT_ID}:${env.ZOOM_CLIENT_SECRET}`).toString('base64'); const form = new URLSearchParams({ grant_type: 'authorization_code', @@ -432,7 +436,9 @@ export async function exchangeZoomCode(env, code, redirectUri, { codeVerifier, f const tokens = await tokenRes.json(); const accessToken = typeof tokens?.access_token === 'string' && tokens.access_token ? tokens.access_token : null; const scope = typeof tokens?.scope === 'string' ? tokens.scope : null; - return { ok: Boolean(accessToken), accessToken, status: tokenRes.status, scope }; + const result = { ok: Boolean(accessToken), accessToken, status: tokenRes.status, scope }; + if (returnRefreshToken) result.refreshToken = typeof tokens?.refresh_token === 'string' ? tokens.refresh_token : null; + return result; } /** diff --git a/worker/club-admin.js b/worker/club-admin.js index a9c9b36..6f992c7 100644 --- a/worker/club-admin.js +++ b/worker/club-admin.js @@ -142,6 +142,7 @@ export const clubDeviceKey = (clubId, deviceId) => `club-device:${clubId}:${devi export const clubMemberKey = (clubId, uid) => `club-member:${clubId}:zoom:${uid}`; export const clubPresetsKey = (clubId) => `club-presets:${clubId}`; export const clubCardsKey = (clubId) => `club-cards:${clubId}`; +export const clubPollTemplateKey = (clubId) => `club-poll-template:${clubId}`; /** * The club a signed-in person administers, found from their uid alone. diff --git a/worker/club-cards.js b/worker/club-cards.js index 2f82c73..49c30e3 100644 --- a/worker/club-cards.js +++ b/worker/club-cards.js @@ -148,10 +148,12 @@ export function buildCards(record) { /** * Who may upload or publish: a named admin or editor on a device that still * holds the club. The same checks, in the same order, as a presets publish. + * The poll template (club-poll-template.js) uses it too, without the bucket. * + * @param {{assets?: boolean}} [options] - whether the route needs CARD_ASSETS * @returns {Promise<{store: Object, clubId: string, club: Object, uid: string}|{response: Response}>} */ -async function requirePublisher(request, env) { +export async function requirePublisher(request, env, { assets = true } = {}) { const session = readSession(request, env); if (!session?.uid) return { response: unauthorized() }; const claims = readClub(request, env); @@ -159,7 +161,7 @@ async function requirePublisher(request, env) { const store = entitlementStore(env); if (!store) return { response: json({ error: 'Club storage is not configured' }, 503) }; - if (!env.CARD_ASSETS) return { response: json({ error: 'Asset storage is not configured' }, 503) }; + if (assets && !env.CARD_ASSETS) return { response: json({ error: 'Asset storage is not configured' }, 503) }; const clubId = await verifiedClubId(env, claims); if (!clubId) return { response: forbidden() }; @@ -212,7 +214,7 @@ async function pruneUnreferenced(env, clubId, keep, now = Date.now()) { } } -async function bumpVer(store, clubId, club) { +export async function bumpVer(store, clubId, club) { const ver = (club.ver ?? 1) + 1; await store.put(clubKey(clubId), JSON.stringify({ ...club, ver })); return ver; diff --git a/worker/club-meetings.js b/worker/club-meetings.js index 52406fe..2a2c06c 100644 --- a/worker/club-meetings.js +++ b/worker/club-meetings.js @@ -2,7 +2,7 @@ import { readClub } from './auth.js'; import { json, methodNotAllowed, unauthorized } from './http.js'; import { entitlementStore, readClubRecord, resolveAccess } from './entitlements.js'; import { verifiedClubId } from './club.js'; -import { shareMeeting } from './club-share.js'; +import { shareMeeting, isShareToken } from './club-share.js'; /** * The club's archive: every finished speech, as it happens. @@ -25,11 +25,21 @@ import { shareMeeting } from './club-share.js'; * The speech keys carry their own metadata so the History list can count a * *live* meeting — speeches and over-times both — from the `list` it was * already doing, with no `get` per speech. + * + * poll::: { pollId, closedAt, categories: + * [{role, label?, candidates, counts, + * byPath?, winners}] } + * + * A closed meeting poll (#80) has a key of its own rather than a field on the + * header, because compaction rewrites the header from the fields it lists. + * Both reads merge them back in as `polls`. */ export const speechKey = (clubId, meetingId, speechId) => `speech:${clubId}:${meetingId}:${speechId}`; export const speechPrefix = (clubId, meetingId) => `speech:${clubId}:${meetingId}:`; export const meetingPrefix = (clubId) => `meeting:${clubId}:`; +export const pollKey = (clubId, meetingId, pollId) => `poll:${clubId}:${meetingId}:${pollId}`; +const pollPrefix = (clubId, meetingId) => `poll:${clubId}:${meetingId}:`; /** No club meets more than a handful of times a day, and 999 is plenty of room. */ const MAX_SEQ = 999; @@ -203,6 +213,86 @@ function meetingMetadata(meetingId, record) { }; } +const MAX_POLL_CATEGORIES = 10; +const MAX_POLL_CANDIDATES = 30; +const pollText = (value) => String(value ?? '').slice(0, 80); +const pollCounts = (value, length) => + Array.from({ length }, (_, i) => (Array.isArray(value) && Number.isFinite(Number(value[i])) ? Math.max(0, Number(value[i])) : 0)); + +/** Everything a stored poll result is allowed to be, and nothing else. */ +function normalizePollResult(raw) { + const categories = (Array.isArray(raw?.categories) ? raw.categories : []).slice(0, MAX_POLL_CATEGORIES).map((c) => { + const candidates = (Array.isArray(c?.candidates) ? c.candidates : []).slice(0, MAX_POLL_CANDIDATES).map(pollText); + const byPath = c?.byPath && typeof c.byPath === 'object' + ? Object.fromEntries(['zoom', 'guest', 'link'].map((path) => [path, pollCounts(c.byPath[path], candidates.length)])) + : null; + return { + role: pollText(c?.role), + ...(c?.label ? { label: pollText(c.label) } : {}), + candidates, + counts: pollCounts(c?.counts, candidates.length), + ...(byPath ? { byPath } : {}), + winners: (Array.isArray(c?.winners) ? c.winners : []).map(pollText).filter((name) => candidates.includes(name)), + }; + }); + return { + pollId: raw.pollId, + closedAt: typeof raw?.closedAt === 'number' ? raw.closedAt : 0, + categories, + // The native Zoom poll's answers, when they came in after the meeting. + ...(raw?.zoom && typeof raw.zoom === 'object' ? { zoom: raw.zoom } : {}), + }; +} + +/** A meeting's closed polls, oldest first. Never throws: a read must not fail on one. */ +export async function readMeetingPolls(env, clubId, meetingId) { + const store = entitlementStore(env); + if (!store) return []; + try { + const keys = await listAll(store, pollPrefix(clubId, meetingId)); + const polls = await Promise.all(keys.map((entry) => store.get(entry.name, 'json').catch(() => null))); + return polls.filter(Boolean).sort((a, b) => (a.closedAt ?? 0) - (b.closedAt ?? 0)); + } catch { + return []; + } +} + +/** + * POST /api/club/meetings//polls — a closed poll's results arrive, + * through the same outbox as speeches. Keyed by pollId, so a retry rewrites + * the same key. + */ +export async function appendPollResults(request, env, meetingId) { + if (request.method !== 'POST') return methodNotAllowed(); + + const claims = readClub(request, env); + if (!claims) return unauthorized(); + if (!parseMeetingId(meetingId)) return json({ error: 'invalid_meeting' }, 400); + + const store = entitlementStore(env); + if (!store) return json({ error: 'Club storage is not configured' }, 503); + + const clubId = await verifiedClubId(env, claims); + if (!clubId) return json({ error: 'forbidden' }, 403); + + const club = await readClubRecord(env, clubId); + if (!club) return json({ error: 'club_not_found' }, 404); + const access = await resolveAccess(env, { clubId, club }); + if (!access.entitled) return json({ error: 'upgrade_required', entitlement: access }, 402); + + let body; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid JSON' }, 400); + } + if (!isShareToken(body?.pollId)) return json({ error: 'invalid_poll_id' }, 400); + + const poll = normalizePollResult(body); + await store.put(pollKey(clubId, meetingId, poll.pollId), JSON.stringify(poll)); + return json({ ok: true, meetingId, pollId: poll.pollId }); +} + /** Whether a speech ran over. Stored on the key so a live count needs no gets. */ const isOvertime = (speech) => speech.color === 'red' || speech.disqualified === true; @@ -366,16 +456,20 @@ export async function readMeeting(request, env, meetingId) { const now = Date.now(); if (meetingSettled(meetingId, now)) { - const compacted = await compactMeeting(env, clubId, meetingId, { now }); + const [compacted, polls] = await Promise.all([ + compactMeeting(env, clubId, meetingId, { now }), + readMeetingPolls(env, clubId, meetingId), + ]); if (!compacted) return json({ error: 'not_found' }, 404); - return json({ meeting: { meetingId, ...compacted } }); + return json({ meeting: { meetingId, ...compacted, polls } }); } // A live meeting is read where it lives, without being folded: folding it // would end the meeting for every other device still timing it. - const [header, speechKeys] = await Promise.all([ + const [header, speechKeys, polls] = await Promise.all([ store.get(meetingKey(clubId, meetingId), 'json').catch(() => null), listAll(store, speechPrefix(clubId, meetingId)), + readMeetingPolls(env, clubId, meetingId), ]); if (!header && !speechKeys.length) return json({ error: 'not_found' }, 404); @@ -398,6 +492,7 @@ export async function readMeeting(request, env, meetingId) { startedAt: header?.startedAt ?? null, live: speechKeys.length > 0, speeches: [...merged.values()].sort(byFinishedAt), + polls, }, }); } @@ -417,6 +512,7 @@ export function handleClubMeetings(request, route, env) { if (extra.length) return json({ error: 'Not found' }, 404); if (!tail) return readMeeting(request, env, decodeURIComponent(meetingId)); if (tail === 'speeches') return appendSpeech(request, env, decodeURIComponent(meetingId)); + if (tail === 'polls') return appendPollResults(request, env, decodeURIComponent(meetingId)); // "End meeting & share": closes the record and gives it a public address. if (tail === 'share') return shareMeeting(request, env, decodeURIComponent(meetingId)); return json({ error: 'Not found' }, 404); diff --git a/worker/club-meetings.test.js b/worker/club-meetings.test.js index 554c364..02c12fb 100644 --- a/worker/club-meetings.test.js +++ b/worker/club-meetings.test.js @@ -421,3 +421,90 @@ describe('reading the archive back', () => { expect((await res.json()).meetings).toEqual([]); }); }); + +/** + * Closed poll results (#80, AC-9). They live under their own key, + * `poll:::`, because compaction rewrites the + * meeting header from the fields it lists and would drop a `polls` field; the + * two reads merge them back in, before and after compaction. + */ +describe('poll results in the archive', () => { + const pollReq = (clubToken, meetingId, body) => + new Request(`https://x/api/club/meetings/${meetingId}/polls`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(clubToken ? { 'x-club': clubToken } : {}) }, + body: JSON.stringify(body), + }); + + const closedPoll = (over = {}) => ({ + pollId: 'ABCDEFGHJKMNPQRS', + closedAt: LIVE_NOW, + categories: [ + { + role: 'Table Topics Speech', + label: 'Best Table Topics Speaker', + candidates: ['Alice', 'Bob'], + counts: [3, 3], + byPath: { zoom: [2, 1], guest: [0, 1], link: [1, 1] }, + winners: ['Alice', 'Bob'], + }, + { role: 'Standard Speech', label: 'Best Speaker', candidates: ['Dan'], counts: [0], byPath: { zoom: [0], guest: [0], link: [0] }, winners: [] }, + ], + ...over, + }); + + const postPoll = (clubToken, body = closedPoll(), meetingId = MEETING) => + handleClubMeetings(pollReq(clubToken, meetingId, body), route(`/${meetingId}/polls`), env); + + const readOne = async (clubToken) => + (await (await handleClubMeetings(readReq(clubToken, `/${MEETING}`), route(`/${MEETING}`), env)).json()).meeting; + + it('stores a closed poll under the meeting, and a live read returns it', async () => { + const { clubToken } = await activated(); + await handleClubMeetings(appendReq(clubToken, MEETING, speech()), route(`/${MEETING}/speeches`), env); + + const res = await postPoll(clubToken); + expect(res.ok).toBe(true); + + const meeting = await readOne(clubToken); + expect(meeting.polls).toHaveLength(1); + expect(meeting.polls[0]).toMatchObject({ + pollId: 'ABCDEFGHJKMNPQRS', + categories: [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', winners: ['Alice', 'Bob'] }, + { role: 'Standard Speech', label: 'Best Speaker', winners: [] }, + ], + }); + // The path split travels with the totals. + expect(meeting.polls[0].categories[0].byPath).toEqual({ zoom: [2, 1], guest: [0, 1], link: [1, 1] }); + }); + + it('still returns it after the meeting is compacted, and on the read after that', async () => { + const { clubToken } = await activated(); + await handleClubMeetings(appendReq(clubToken, MEETING, speech()), route(`/${MEETING}/speeches`), env); + await postPoll(clubToken); + + vi.setSystemTime(SETTLED_NOW); + const compacted = await readOne(clubToken); + expect([...kv.store.keys()].filter((k) => k.startsWith('speech:'))).toHaveLength(0); + expect(compacted.polls?.[0]?.pollId).toBe('ABCDEFGHJKMNPQRS'); + + const again = await readOne(clubToken); + expect(again.polls?.[0]?.categories?.[0]?.winners).toEqual(['Alice', 'Bob']); + }); + + it('is safe to send twice: one poll, one entry', async () => { + const { clubToken } = await activated(); + await handleClubMeetings(appendReq(clubToken, MEETING, speech()), route(`/${MEETING}/speeches`), env); + await postPoll(clubToken); + await postPoll(clubToken); + + expect((await readOne(clubToken)).polls).toHaveLength(1); + }); + + it('turns away a poll with no club token, and one for a meetingId we never mint', async () => { + const { clubToken } = await activated(); + expect((await postPoll(null)).status).toBe(401); + expect((await postPoll(clubToken, closedPoll(), 'not-a-meeting')).status).toBe(400); + }); +}); diff --git a/worker/club-poll-template.js b/worker/club-poll-template.js new file mode 100644 index 0000000..ca350fa --- /dev/null +++ b/worker/club-poll-template.js @@ -0,0 +1,103 @@ +import { json, methodNotAllowed } from './http.js'; +import { entitlementStore } from './entitlements.js'; +import { clubPollTemplateKey } from './club-admin.js'; +import { bumpVer, requirePublisher } from './club-cards.js'; +import { capturePostHogEvent } from './posthog.js'; + +/** + * The club's meeting poll template (#80): the ballot setup an admin or editor + * publishes once, which every club device then sets its polls up from. + * + * PUT /api/club/poll-template {categories: [{role, label?, slots}], + * options: {postToChat, showOnStage, expiryHours}} + * + * Stored at `club-poll-template:`; publishing bumps `club:.ver`, + * so devices replace their copy on the next refresh, the way presets and card + * sets travel. A lapsed club keeps it stored; devices stop using it because the + * club state says `entitled: false`, as they do for presets. + */ + +const MAX_CATEGORIES = 10; +const MAX_SLOTS = 20; +const clip = (value) => (typeof value === 'string' ? value.trim().slice(0, 80) : ''); + +/** + * The same shape packages/shared/polls.js normalizeTemplate keeps on the + * device: a category with no role is dropped, unknown keys go. + * + * @returns {{categories: Array, options: Object}|null} null with no category left + */ +export function normalizePollTemplate(raw) { + const categories = (Array.isArray(raw?.categories) ? raw.categories : []) + .map((c) => { + const label = clip(c?.label); + const slots = Math.round(Number(c?.slots)); + return { + role: clip(c?.role), + ...(label ? { label } : {}), + slots: Number.isFinite(slots) ? Math.min(MAX_SLOTS, Math.max(1, slots)) : 3, + }; + }) + .filter((c) => c.role) + .slice(0, MAX_CATEGORIES); + if (!categories.length) return null; + const options = raw?.options ?? {}; + const hours = Number(options.expiryHours); + return { + categories, + options: { + postToChat: typeof options.postToChat === 'boolean' ? options.postToChat : true, + showOnStage: typeof options.showOnStage === 'boolean' ? options.showOnStage : false, + expiryHours: Number.isFinite(hours) && hours > 0 ? Math.min(24, hours) : 24, + }, + }; +} + +/** The club's published template, or null. Never throws: it runs on the app-start refresh. */ +export async function readClubPollTemplate(env, clubId) { + const store = entitlementStore(env); + if (!store || !clubId) return null; + try { + return (await store.get(clubPollTemplateKey(clubId), 'json')) ?? null; + } catch { + return null; + } +} + +/** The template as a device stores it. */ +export function buildPollTemplate(record) { + const template = record ? normalizePollTemplate(record) : null; + if (!template) return null; + return { ...template, publishedBy: record.publishedBy ?? null, publishedAt: record.publishedAt ?? null }; +} + +/** PUT /api/club/poll-template */ +export async function handleClubPollTemplate(request, env, { ctx } = {}) { + if (request.method !== 'PUT') return methodNotAllowed(); + const context = await requirePublisher(request, env, { assets: false }); + if (context.response) return context.response; + const { store, clubId, club, uid } = context; + + let body; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid JSON' }, 400); + } + const template = normalizePollTemplate(body); + if (!template) return json({ error: 'invalid_poll_template' }, 400); + + const record = { ...template, publishedBy: uid, publishedAt: Date.now() }; + await store.put(clubPollTemplateKey(clubId), JSON.stringify(record)); + const ver = await bumpVer(store, clubId, club); + + const capture = capturePostHogEvent(env, 'poll_template_published', { + distinct_id: `club:${clubId}`, + $process_person_profile: false, + categories: template.categories.length, + }); + if (typeof ctx?.waitUntil === 'function') ctx.waitUntil(capture); + else await capture; + + return json({ ver, pollTemplate: buildPollTemplate(record) }); +} diff --git a/worker/club-poll-template.test.js b/worker/club-poll-template.test.js new file mode 100644 index 0000000..3b85488 --- /dev/null +++ b/worker/club-poll-template.test.js @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { handleClub, handleClubActivate, handleClubState } from './club.js'; +import { createClubFromPending, clubMemberKey } from './club-admin.js'; +import { mintSessionToken } from './session-token.js'; +import { verifyClubToken } from './club-token.js'; +import { clubKey } from './entitlements.js'; + +/** + * The club's poll template (#80, AC-10): the ballot setup an admin or editor + * publishes once and every club device receives on its next refresh, the + * same way presets and card sets travel. + * + * Contract: `PUT /api/club/poll-template` with a session and a club token, + * body `{categories: [{role, label?, slots}], options: {postToChat, + * showOnStage, expiryHours}}`, answers `{ver, pollTemplate}` and bumps the + * club's `ver`; `GET /api/club` carries `pollTemplate` (null until published). + */ + +const SIGNING_KEY = 'test-session-signing-key'; + +const TEMPLATE = { + categories: [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', slots: 3 }, + { role: 'Standard Speech', slots: 3 }, + { role: 'Contest Speech', label: 'Best Contestant', slots: 2 }, + ], + options: { postToChat: true, showOnStage: false, expiryHours: 12 }, +}; + +function makeKv() { + const store = new Map(); + return { + store, + get: async (key, type) => { + const raw = store.get(key); + if (raw === undefined) return null; + return (type?.type ?? type) === 'json' ? JSON.parse(raw) : raw; + }, + put: async (key, value) => { store.set(key, value); }, + delete: async (key) => { store.delete(key); }, + list: async ({ prefix = '' } = {}) => ({ + keys: [...store.keys()].filter((k) => k.startsWith(prefix)).map((name) => ({ name })), + list_complete: true, + }), + }; +} + +function makeBucket() { + const store = new Map(); + return { + store, + head: async (key) => (store.has(key) ? { size: 1 } : null), + get: async (key) => (store.has(key) ? { body: store.get(key), httpMetadata: {} } : null), + put: async (key, bytes) => { store.set(key, bytes); }, + delete: async (key) => { store.delete(key); }, + list: async () => ({ objects: [], truncated: false }), + }; +} + +let kv; +let env; +let seeded; + +beforeEach(() => { + kv = makeKv(); + env = { PROFILES: kv, CARD_ASSETS: makeBucket(), SESSION_SIGNING_KEY: SIGNING_KEY, ENTITLEMENT_ENFORCE: '1', FLAGS_FORCE: '1' }; + seeded = null; +}); + +/** Activate one device on the test's club, as `uid` with `role`. */ +async function activated({ uid, role } = {}) { + seeded ??= await createClubFromPending(env, { clubName: 'Downtown Speakers', uid: 'buyer-uid' }, { code: 'DTSP7K2QM9' }); + const { clubId } = seeded; + if (uid && role) { + await kv.put(clubMemberKey(clubId, uid), JSON.stringify({ role, displayName: null, addedAt: 1, revokedAt: null })); + } + const res = await handleClubActivate( + new Request('https://x/api/club/activate', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(uid ? { authorization: `Bearer ${mintSessionToken(uid, SIGNING_KEY)}` } : {}), + }, + body: JSON.stringify({ code: 'DTSP-7K2QM9' }), + }), + env + ); + const body = await res.json(); + return { clubId, ...body, claims: verifyClubToken(body.clubToken, SIGNING_KEY) }; +} + +const headers = (clubToken, uid) => ({ + ...(clubToken ? { 'x-club': clubToken } : {}), + ...(uid ? { authorization: `Bearer ${mintSessionToken(uid, SIGNING_KEY)}` } : {}), +}); + +function publish(clubToken, uid, body = TEMPLATE) { + const url = new URL('https://www.timer-dev.simple-tech.app/api/club/poll-template'); + return handleClub( + new Request(url, { method: 'PUT', headers: { 'content-type': 'application/json', ...headers(clubToken, uid) }, body: JSON.stringify(body) }), + url, + env + ); +} + +const clubState = async (clubToken) => + (await handleClubState(new Request('https://x/api/club', { headers: { 'x-club': clubToken } }), env)).json(); + +const readJson = async (res) => { + try { + return await res.json(); + } catch { + return null; + } +}; + +// --------------------------------------------------------------------------- + +describe('PUT /api/club/poll-template', () => { + it('lets an editor publish, bumps ver, and every club device sees it on its next refresh', async () => { + const editor = await activated({ uid: 'editor-uid', role: 'editor' }); + const member = await activated(); + expect((await clubState(member.clubToken)).pollTemplate ?? null).toBeNull(); + const before = (await clubState(member.clubToken)).ver; + + const res = await publish(editor.clubToken, 'editor-uid'); + const body = await readJson(res); + + expect(res.status).toBe(200); + expect(body?.ver).toBe(before + 1); + const state = await clubState(member.clubToken); + expect(state.ver).toBe(before + 1); + expect(state.pollTemplate).toMatchObject({ + categories: [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', slots: 3 }, + { role: 'Standard Speech', slots: 3 }, + { role: 'Contest Speech', label: 'Best Contestant', slots: 2 }, + ], + options: { postToChat: true, showOnStage: false, expiryHours: 12 }, + }); + }); + + it('lets an admin publish too', async () => { + const admin = await activated({ uid: 'admin-uid', role: 'admin' }); + expect((await publish(admin.clubToken, 'admin-uid')).status).toBe(200); + }); + + // Publishing changes every timer's ballot: not for whoever holds the code. + it('refuses a member with 403, and a request missing either credential with 401', async () => { + const member = await activated({ uid: 'member-uid', role: 'member' }); + expect((await publish(member.clubToken, 'member-uid')).status).toBe(403); + expect((await publish(member.clubToken, null)).status).toBe(401); + expect((await publish(null, 'member-uid')).status).toBe(401); + expect((await clubState(member.clubToken)).pollTemplate ?? null).toBeNull(); + }); + + it('refuses a template with no categories', async () => { + const editor = await activated({ uid: 'editor-uid', role: 'editor' }); + expect((await publish(editor.clubToken, 'editor-uid', { categories: [], options: {} })).status).toBe(400); + }); + + it('refuses a lapsed club with the upgrade answer, and keeps what was already published', async () => { + const editor = await activated({ uid: 'editor-uid', role: 'editor' }); + expect((await publish(editor.clubToken, 'editor-uid')).status).toBe(200); + + const club = JSON.parse(kv.store.get(clubKey(editor.clubId))); + await kv.put(clubKey(editor.clubId), JSON.stringify({ ...club, status: 'canceled', currentPeriodEnd: Date.now() - 1000 })); + + expect((await publish(editor.clubToken, 'editor-uid', { ...TEMPLATE, options: { ...TEMPLATE.options, expiryHours: 2 } })).status).toBe(402); + // Stored but unused: the device decides from `entitled`, as it does for presets. + const state = await clubState(editor.clubToken); + expect(state.entitled).toBe(false); + expect(state.pollTemplate?.options?.expiryHours).toBe(12); + }); +}); diff --git a/worker/club-share.js b/worker/club-share.js index 7f3e056..47cacdd 100644 --- a/worker/club-share.js +++ b/worker/club-share.js @@ -4,7 +4,7 @@ import { json, methodNotAllowed, unauthorized } from './http.js'; import { entitlementStore, readClubRecord, resolveAccess } from './entitlements.js'; import { verifiedClubId, buildKit } from './club.js'; import { CODE_ALPHABET } from './club-admin.js'; -import { compactMeeting, meetingKey, meetingDate, parseMeetingId } from './club-meetings.js'; +import { compactMeeting, meetingKey, meetingDate, parseMeetingId, readMeetingPolls } from './club-meetings.js'; /** * A meeting that travels: an image and a link. @@ -227,6 +227,28 @@ function speechRow(speech) { `; } +/** + * The meeting poll winners (#80), one row per category: its label, else its + * role; every tied name; "No votes" when nobody voted. Nothing at all for a + * meeting with no closed poll, so that page reads exactly as it always did. + */ +function winnersSection(polls) { + const rows = (Array.isArray(polls) ? polls : []).flatMap((poll) => (Array.isArray(poll?.categories) ? poll.categories : [])); + if (!rows.length) return ''; + return `
+ + ${rows + .map((category) => { + const names = Array.isArray(category?.winners) ? category.winners : []; + return ` + + + `; + }) + .join('')} +
VoteWinners
${escapeHtml(category?.label || category?.role)}${names.length ? escapeHtml(names.join(', ')) : 'No votes'}
`; +} + /** * The shared report, as HTML a crawler can read without running anything. * @@ -306,7 +328,7 @@ footer a { color: #2563eb; } ${speeches.map(speechRow).join('')} ` : `

No speeches were timed in this meeting.

` - } + }${winnersSection(meeting?.polls)}