From f6f5e33672bf82f3931909fcd7dfe56acfad7000 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:25:51 +0000 Subject: [PATCH 1/8] plan(#80): Meeting polls: vote for Best Table Topics Speaker, Best Speaker, and Best Evaluator Co-Authored-By: Claude Opus 5.5 --- .factory/plans/80-meeting-polls.md | 308 +++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 .factory/plans/80-meeting-polls.md diff --git a/.factory/plans/80-meeting-polls.md b/.factory/plans/80-meeting-polls.md new file mode 100644 index 0000000..1ca2fec --- /dev/null +++ b/.factory/plans/80-meeting-polls.md @@ -0,0 +1,308 @@ +# 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. It has two ways to vote, shipped together: a web link (the default) +and a native Zoom poll (host only). Club and personal templates hold the ballot setup. + +## 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`. + +**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. It returns a 16-character + Crockford id (the `/r/` token code) and expires after `expiryHours` (default 24). + The ballot holds candidate names and labels only. + - `GET /api/polls//ballot` (public). + - `POST /api/polls//votes` (public): body `{device, choices{catIndex:candIndex}}`. + The DO refuses a category this device already voted in. + - `GET /api/polls//results` (owner only). + - `POST /api/polls//close` (owner only). It returns `{counts, winners}`. + - 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. +5. **Zoom app UI:** a **POLL** tab after REPORT (`components/PollTab.jsx`). It is hidden + unless `flag('polls')`. A free plan sees the tab locked, with `UpgradeModal source="polls"`. + - **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 and shows the link with Copy. + - Posts to chat with `sendMessageToChat` when `postToChat` is on. If that fails it + falls back to Copy with a toast. + - Add `sendMessageToChat` to `USED_SDK_APIS` as optional. + - **Live:** counts every 5 s while the tab is open (`GET results`), shown only in this + sidebar. + - **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. It **stores no token**. + - **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, so the app shows no live counts and no reveal. + - **Scopes:** add `meeting:write:poll` and `meeting:read:meeting` in the Marketplace + (a human step; see Q4). +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_vote_cast` (Worker, no person data), `poll_closed`, + `poll_template_published` and `zoom_poll_created`. + +**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. +- Storing Zoom refresh tokens to fetch native poll results after the meeting: this adds + stored credentials and a purge path for a best-effort feature. It is listed as follow-up. +- 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. +- 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. +- 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. No Zoom token is stored. + +## 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), `PollTab.test.jsx` (host only) | — | MP-13, MP-14 (human: Zoom authorization; blocked until the scopes are approved) | + +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`. + +## Risk +**High.** +- New public, unauthenticated write endpoints (votes) and a new storage primitive (a Durable + Object binding plus migration in `wrangler.jsonc`, both envs). +- New Zoom OAuth scopes and a Marketplace re-review for B. +- Changes to the club state payload and archive compaction. + +riskHigh paths touched: +- `wrangler.jsonc` (the DO binding and migration, plus the vote rate limiter). +- `worker/flags.js` (`polls` and `polls_zoom`). +- `worker/auth.js` is reused for `exchangeZoomCode` (code-exchange only; refresh tokens are + still discarded). + +Mitigations: +- Both flags default off. +- B stays dark until review. +- No personal data in the vote URL or the stored votes: device tokens are hashed in the DO. +- Expiry deletes poll storage. + +Size: the largest factory PR so far, at roughly 30–40 files. The commit order above keeps it +reviewable. + +## Open questions +- Q1. **Vote storage:** the plan uses a Durable Object per poll (new `wrangler.jsonc` + binding) for exact counts at Close. Is a new DO binding OK, or do you prefer KV with a + ~60 s "counting…" delay after Close? + *Default: Durable Object.* +- Q2. **Web app:** ship the POLL tab in the Zoom app only, with the web app getting it in a + follow-up? The vote page and Worker serve both apps. + *Default: Zoom app only in this PR. Shared code and the Worker are written for both.* +- Q3. **Vote rate limit:** add a per-IP limiter on `POST /votes` (a new `unsafe.bindings` + entry, like `CLUB_ACTIVATE_LIMITER`)? + *Default: yes, 30 votes per minute per IP, failing open.* +- Q4. **Zoom scopes (B):** someone with Marketplace access has to add `meeting:write:poll` + and `meeting:read:meeting` and submit for review. The Zoom station can't test MP-13/MP-14 + until the dev app has them. + *Default: B ships behind `polls_zoom` (off), and those steps are marked ⏭ with + `needs:zoom-test`.* +- Q5. **Native poll results after the meeting** (`GET /past_meetings/{id}/polls`): this needs + a stored refresh token. + *Default: not in this PR; file it as a follow-up issue.* +- Q6. **Report image and shared `/r/` page:** should they show the winners too? + *Default: no. Results show on the Report tab and in the archive API. The PNG and the shared + page come in a follow-up.* +- Q7. **Who runs the poll:** only the device that created it sees counts and can close it. + A Vote Counter on another device would have to create the poll themselves. + *Default: yes, the owner is the creating user (uid).* +- Q8. **Proposed:** a category with zero votes shows "No votes" and has no winner. + *Default: as stated.* From 4880b78c94fd21db8d47b89406c491cb6a61c68f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 01:05:01 +0000 Subject: [PATCH 2/8] plan(#80): apply review Co-Authored-By: Claude Opus 5.5 --- .factory/plans/80-meeting-polls.md | 237 +++++++++++++++++++++-------- 1 file changed, 174 insertions(+), 63 deletions(-) diff --git a/.factory/plans/80-meeting-polls.md b/.factory/plans/80-meeting-polls.md index 1ca2fec..21efb96 100644 --- a/.factory/plans/80-meeting-polls.md +++ b/.factory/plans/80-meeting-polls.md @@ -4,8 +4,18 @@ 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. It has two ways to vote, shipped together: a web link (the default) -and a native Zoom poll (host only). Club and personal templates hold the ballot setup. +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 @@ -85,6 +95,19 @@ issue overrides that: A and B ship together (decided 2026-11-13). No code exists - 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`, @@ -123,14 +146,32 @@ until Zoom approves the new scopes. 2. **Worker** (`worker/polls.js`, plus a Durable Object `PollRoom` with one instance per poll): - Routes: - - `POST /api/polls` (session + Pro) creates the poll. It returns a 16-character - Crockford id (the `/r/` token code) and expires after `expiryHours` (default 24). - The ballot holds candidate names and labels only. + - `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 `{device, choices{catIndex:candIndex}}`. - The DO refuses a category this device already voted in. - - `GET /api/polls//results` (owner only). - - `POST /api/polls//close` (owner only). It returns `{counts, winners}`. + - `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 @@ -147,8 +188,20 @@ until Zoom approves the new scopes. 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')`. A free plan sees the tab locked, with `UpgradeModal source="polls"`. + 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. @@ -157,12 +210,15 @@ until Zoom approves the new scopes. → `role_added`, the same as Edit Rules. - "Save as my template". An admin or editor also gets "Publish to club". - **Open:** - - Creates the poll and shows the link with Copy. - - Posts to chat with `sendMessageToChat` when `postToChat` is on. If that fails it - falls back to Copy with a toast. - - Add `sendMessageToChat` to `USED_SDK_APIS` as optional. + - 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. + 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 @@ -174,23 +230,47 @@ until Zoom approves the new scopes. `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. It **stores no token**. + 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. + - The rotated refresh token Zoom returns is never stored beyond that one call. + - 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, so the app shows no live counts and no reveal. - - **Scopes:** add `meeting:write:poll` and `meeting:read:meeting` in the Marketplace - (a human step; see Q4). + 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_vote_cast` (Worker, no person data), `poll_closed`, - `poll_template_published` and `zoom_poll_created`. +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. -- Storing Zoom refresh tokens to fetch native poll results after the meeting: this adds - stored credentials and a purge path for a best-effort feature. It is listed as follow-up. +- 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. @@ -198,6 +278,7 @@ until Zoom approves the new scopes. ## 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 @@ -211,7 +292,8 @@ until Zoom approves the new scopes. - 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. + 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. @@ -232,7 +314,27 @@ until Zoom approves the new scopes. - 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. No Zoom token is stored. + 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`) | @@ -249,60 +351,69 @@ until Zoom approves the new scopes. | 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), `PollTab.test.jsx` (host only) | — | MP-13, MP-14 (human: Zoom authorization; blocked until the scopes are approved) | +| 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) and a new storage primitive (a Durable - Object binding plus migration in `wrangler.jsonc`, both envs). -- New Zoom OAuth scopes and a Marketplace re-review for B. -- Changes to the club state payload and archive compaction. +- 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. 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 the vote rate limiter). +- `wrangler.jsonc` (the DO binding and migration, plus `POLL_VOTE_LIMITER`). - `worker/flags.js` (`polls` and `polls_zoom`). -- `worker/auth.js` is reused for `exchangeZoomCode` (code-exchange only; refresh tokens are - still discarded). +- `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. -- B stays dark until review. -- No personal data in the vote URL or the stored votes: device tokens are hashed in the DO. -- Expiry deletes poll storage. +- 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 30–40 files. The commit order above keeps it +Size: the largest factory PR so far, at roughly 40–50 files. The commit order above keeps it reviewable. ## Open questions -- Q1. **Vote storage:** the plan uses a Durable Object per poll (new `wrangler.jsonc` - binding) for exact counts at Close. Is a new DO binding OK, or do you prefer KV with a - ~60 s "counting…" delay after Close? - *Default: Durable Object.* -- Q2. **Web app:** ship the POLL tab in the Zoom app only, with the web app getting it in a - follow-up? The vote page and Worker serve both apps. - *Default: Zoom app only in this PR. Shared code and the Worker are written for both.* -- Q3. **Vote rate limit:** add a per-IP limiter on `POST /votes` (a new `unsafe.bindings` - entry, like `CLUB_ACTIVATE_LIMITER`)? - *Default: yes, 30 votes per minute per IP, failing open.* -- Q4. **Zoom scopes (B):** someone with Marketplace access has to add `meeting:write:poll` - and `meeting:read:meeting` and submit for review. The Zoom station can't test MP-13/MP-14 - until the dev app has them. - *Default: B ships behind `polls_zoom` (off), and those steps are marked ⏭ with - `needs:zoom-test`.* -- Q5. **Native poll results after the meeting** (`GET /past_meetings/{id}/polls`): this needs - a stored refresh token. - *Default: not in this PR; file it as a follow-up issue.* -- Q6. **Report image and shared `/r/` page:** should they show the winners too? - *Default: no. Results show on the Report tab and in the archive API. The PNG and the shared - page come in a follow-up.* -- Q7. **Who runs the poll:** only the device that created it sees counts and can close it. - A Vote Counter on another device would have to create the poll themselves. - *Default: yes, the owner is the creating user (uid).* -- Q8. **Proposed:** a category with zero votes shows "No votes" and has no winner. - *Default: as stated.* +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**. From 26933dcde0de0ac7ff0265bf0df1ff85b9554b62 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 01:35:04 +0000 Subject: [PATCH 3/8] =?UTF-8?q?test(#80):=20oracle=20for=20AC-1=E2=80=A6AC?= =?UTF-8?q?-17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing tests written from the approved plan before any implementation: Worker (polls, native Zoom poll, club template, archive, /r/ winners, /vote/ routing and CSP, 402 gate), shared logic (ballot, template, winners, refresh, sync, outbox, report image), the POLL tab and the Stage winners panel, a Playwright spec (the vote page runs the real Worker in-process), and the new meeting-polls Zoom-client spec. Co-Authored-By: Claude Opus 5.5 --- .factory/oracles/80.json | 22 + apps/zoom-app/src/components/PollTab.test.jsx | 549 +++++++++++++++++ .../src/components/TimerStage.test.jsx | 85 +++ apps/zoom-app/src/utils/zoomSdk.test.js | 16 + e2e/zoom-client/INDEX.md | 3 +- e2e/zoom-client/meeting-polls.md | 160 +++++ e2e/zoom.meeting-polls.spec.js | 305 ++++++++++ packages/shared/__tests__/clubArchive.test.js | 81 +++ packages/shared/__tests__/polls.test.js | 259 ++++++++ .../shared/__tests__/profileMerge.test.js | 16 + packages/shared/__tests__/reportImage.test.js | 57 ++ worker/club-meetings.test.js | 87 +++ worker/club-poll-template.test.js | 175 ++++++ worker/club-share.test.js | 93 +++ worker/csp.test.js | 57 ++ worker/durable-object-fake.js | 267 ++++++++ worker/gating.test.js | 23 + worker/polls-zoom.test.js | 452 ++++++++++++++ worker/polls.test.js | 572 ++++++++++++++++++ worker/routing.test.js | 36 ++ 20 files changed, 3314 insertions(+), 1 deletion(-) create mode 100644 .factory/oracles/80.json create mode 100644 apps/zoom-app/src/components/PollTab.test.jsx create mode 100644 apps/zoom-app/src/components/TimerStage.test.jsx create mode 100644 e2e/zoom-client/meeting-polls.md create mode 100644 e2e/zoom.meeting-polls.spec.js create mode 100644 packages/shared/__tests__/polls.test.js create mode 100644 worker/club-poll-template.test.js create mode 100644 worker/durable-object-fake.js create mode 100644 worker/polls-zoom.test.js create mode 100644 worker/polls.test.js 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/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/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.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/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index 14fea04..00012c6 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -13,6 +13,7 @@ Read this first. Update it in the same PR as any spec change. | [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) | +| [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/meeting-polls.md b/e2e/zoom-client/meeting-polls.md new file mode 100644 index 0000000..3388335 --- /dev/null +++ b/e2e/zoom-client/meeting-polls.md @@ -0,0 +1,160 @@ +--- +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/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/reportImage.js + - worker/club-meetings.js + - worker/club-share.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. + +When the implementation lands, add its files to `covers` (`PollTab.jsx`, `PollVote.jsx`, +`worker/polls.js`, `packages/shared/polls.js`); they are left out here only because +`npm run test:specs` refuses a path that does not exist yet. + +## 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 to 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 "Post to chat" 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.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/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.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.test.js b/worker/club-share.test.js index 310f5a5..e1df5af 100644 --- a/worker/club-share.test.js +++ b/worker/club-share.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { handleSharedReport, + renderSharePage, isShareToken, mintShareToken, shareKey, @@ -440,3 +441,95 @@ describe('routing', () => { expect(res.headers.get('location')).toBe('https://www.timer.simple-tech.app/r/ZZZZZZZZZZZZZZZZ'); }); }); + +/** + * Poll winners on the shared page (#80, AC-17, Q6). Each category names its + * winners, ties included; a category nobody voted in says "No votes"; a + * meeting with no poll looks exactly as it did before polls existed. + */ +describe('poll winners on /r/', () => { + const page = (token) => { + const url = new URL(`${ORIGIN}/r/${token}`); + return handleSharedReport(new Request(url), url, env); + }; + + const pollReq = (clubToken, body) => + new Request(`https://x/api/club/meetings/${MEETING}/polls`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-club': clubToken }, + body: JSON.stringify(body), + }); + + const closedPoll = { + pollId: 'ABCDEFGHJKMNPQRS', + closedAt: NOW, + categories: [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', candidates: ['Carol', 'Dmitri'], counts: [4, 4], winners: ['Carol', 'Dmitri'] }, + { role: 'Standard Speech', label: 'Best Speaker', candidates: ['Eleanor'], counts: [5], winners: ['Eleanor'] }, + { role: 'Table Topics Evaluation', candidates: ['Frank'], counts: [0], winners: [] }, + ], + }; + + it('lists each category with its winners, ties included, and "No votes" for an empty one', async () => { + const activation = await activated(); + await timedMeeting(activation.clubToken); + const posted = await handleClubMeetings(pollReq(activation.clubToken, closedPoll), route(`/${MEETING}/polls`), env); + expect(posted.ok).toBe(true); + const { token } = await (await share(activation.clubToken)).json(); + + const html = await (await page(token)).text(); + + expect(html).toContain('Best Table Topics Speaker'); + expect(html).toContain('Carol'); + expect(html).toContain('Dmitri'); + expect(html).toContain('Best Speaker'); + expect(html).toContain('Eleanor'); + // No label: the role names the category. + expect(html).toContain('Table Topics Evaluation'); + expect(html).toContain('No votes'); + }); + + it('escapes a winner\'s name rather than letting it become markup', async () => { + const activation = await activated(); + await timedMeeting(activation.clubToken); + await handleClubMeetings( + pollReq(activation.clubToken, { + ...closedPoll, + categories: [{ role: 'Standard Speech', label: 'Best Speaker', candidates: [''], counts: [1], winners: [''] }], + }), + route(`/${MEETING}/polls`), + env + ); + const { token } = await (await share(activation.clubToken)).json(); + + const html = await (await page(token)).text(); + expect(html).toContain('Best Speaker'); + expect(html).not.toContain(''); + expect(html).toContain('<script>x</script>'); + }); + + it('renders a meeting with no poll exactly as before', () => { + const meeting = { date: '2026-09-29', title: null, speeches: [speech()] }; + const base = { clubName: 'Downtown Speakers', primaryColor: '#772432', logoUrl: null, imageUrl: 'i', canonicalUrl: 'c', fullImageUrl: 'f' }; + + const without = renderSharePage({ ...base, meeting }); + expect(renderSharePage({ ...base, meeting: { ...meeting, polls: [] } })).toBe(without); + expect(without).not.toContain('No votes'); + expect(without).not.toMatch(/winner/i); + }); + + it('renders the winners straight from meeting.polls', () => { + const html = renderSharePage({ + clubName: 'Downtown Speakers', + primaryColor: '#772432', + logoUrl: null, + imageUrl: 'i', + canonicalUrl: 'c', + fullImageUrl: 'f', + meeting: { date: '2026-09-29', title: null, speeches: [speech()], polls: [closedPoll] }, + }); + expect(html).toContain('Carol'); + expect(html).toContain('Eleanor'); + expect(html).toContain('No votes'); + }); +}); diff --git a/worker/csp.test.js b/worker/csp.test.js index d6baeb9..25ef3f4 100644 --- a/worker/csp.test.js +++ b/worker/csp.test.js @@ -106,3 +106,60 @@ describe('the root policy the Worker serves', () => { expect(fontSrc).toContain('https://fonts.gstatic.com'); }); }); + +/** + * The backup voting page (#80): GET /vote/ is Worker-rendered HTML on the + * www host whose inline script posts the vote to /api/polls//votes and + * keeps a device token in localStorage. It must arrive with the root policy, + * and that policy must allow exactly those two things: inline script and a + * same-origin fetch. The dev server sends no CSP, so nothing else would catch a + * page that renders but cannot vote. + */ +describe('the voting page is served under a policy that lets it vote', () => { + it('answers /vote/ with ROOT_CSP: inline script and same-origin connect', async () => { + const { default: worker, PollRoom } = await import('./index.js'); + const { makeDurableObjectNamespace, makeKv, makeCtx, makeEmptyAssets } = await import('./durable-object-fake.js'); + const { mintSessionToken } = await import('./session-token.js'); + const { entitlementKey } = await import('./entitlements.js'); + const env = { + PROFILES: makeKv(), + ASSETS: makeEmptyAssets(), + SESSION_SIGNING_KEY: 'test-session-signing-key', + ZOOM_CLIENT_SECRET: 'test-zoom-client-secret', + ENTITLEMENT_ENFORCE: '1', + FLAGS_FORCE: '1', + WEB_ORIGIN: 'https://www.timer.simple-tech.app', + }; + env.POLL_ROOM = makeDurableObjectNamespace(PollRoom, () => env); + await env.PROFILES.put(entitlementKey('owner-uid'), JSON.stringify({ status: 'active', currentPeriodEnd: Date.now() + 1e10 })); + const ctx = makeCtx(); + + const created = await worker.fetch( + new Request('https://www.timer.simple-tech.app/api/polls', { + method: 'POST', + headers: { + host: 'www.timer.simple-tech.app', + 'content-type': 'application/json', + authorization: `Bearer ${mintSessionToken('owner-uid', 'test-session-signing-key')}`, + }, + body: JSON.stringify({ ballot: { categories: [{ role: 'Standard Speech', candidates: ['Alice', 'Bob'] }] } }), + }), + env, + ctx + ); + expect(created.ok).toBe(true); + const { id } = await created.json(); + + const res = await worker.fetch( + new Request(`https://www.timer.simple-tech.app/vote/${id}`, { headers: { host: 'www.timer.simple-tech.app' } }), + env, + ctx + ); + const policy = res.headers.get('content-security-policy') ?? ''; + + expect(res.status).toBe(200); + expect(policy.match(/script-src ([^;]*)/)?.[1]).toContain("'unsafe-inline'"); + expect(policy.match(/connect-src ([^;]*)/)?.[1]).toContain("'self'"); + expect(policy).toContain("form-action 'self'"); + }); +}); diff --git a/worker/durable-object-fake.js b/worker/durable-object-fake.js new file mode 100644 index 0000000..b8cff55 --- /dev/null +++ b/worker/durable-object-fake.js @@ -0,0 +1,267 @@ +import crypto from 'node:crypto'; + +/** + * Test-only: an in-memory Durable Object namespace, for the poll tests + * (worker/polls.test.js, worker/polls-zoom.test.js and the Playwright vote-page + * test). Not matched by vitest's `**\/*.test.js` include, and never imported by + * worker/index.js, so none of this reaches the deployed bundle. + * + * It runs the real Durable Object class against a Map-backed `state.storage` + * with the transactional key-value API (get/put/delete/deleteAll/list) and one + * alarm per object, which is the surface both KV-backed and SQLite-backed + * objects offer. A stub answers both `stub.fetch(request)` and RPC calls + * (`stub.someMethod(...)`), so the Worker may talk to its object either way. + * `storage.sql` is deliberately absent. + * + * Alarms never fire on their own: a test moves the clock and calls + * `runDueAlarms()`, the way the runtime would once the time came. + */ + +const clone = (value) => (value === undefined ? undefined : structuredClone(value)); + +// RPC may carry a Request, a Response or a stream, which the runtime passes +// through and structuredClone cannot copy: those cross by reference. +const rpcClone = (value) => { + try { + return clone(value); + } catch { + return value; + } +}; + +function makeStorage() { + const data = new Map(); + let alarm = null; + + const storage = { + data, + async get(keyOrKeys) { + if (Array.isArray(keyOrKeys)) { + const out = new Map(); + for (const key of keyOrKeys) if (data.has(key)) out.set(key, clone(data.get(key))); + return out; + } + return clone(data.get(keyOrKeys)); + }, + async put(keyOrEntries, value) { + if (typeof keyOrEntries === 'object' && keyOrEntries !== null) { + for (const [key, v] of Object.entries(keyOrEntries)) data.set(key, clone(v)); + return; + } + data.set(keyOrKeys(keyOrEntries), clone(value)); + }, + async delete(keyOrKeys) { + if (Array.isArray(keyOrKeys)) { + let removed = 0; + for (const key of keyOrKeys) if (data.delete(key)) removed += 1; + return removed; + } + return data.delete(keyOrKeys); + }, + async deleteAll() { + data.clear(); + }, + async list({ prefix = '', start, end, limit, reverse = false } = {}) { + let keys = [...data.keys()].filter((k) => k.startsWith(prefix)).sort(); + if (start !== undefined) keys = keys.filter((k) => k >= start); + if (end !== undefined) keys = keys.filter((k) => k < end); + if (reverse) keys.reverse(); + if (limit !== undefined) keys = keys.slice(0, limit); + return new Map(keys.map((k) => [k, clone(data.get(k))])); + }, + async getAlarm() { + return alarm; + }, + async setAlarm(when) { + alarm = when instanceof Date ? when.getTime() : Number(when); + }, + async deleteAlarm() { + alarm = null; + }, + async transaction(fn) { + return fn(storage); + }, + transactionSync(fn) { + return fn(storage); + }, + async sync() {}, + // Read by the namespace; not part of the runtime API. + peekAlarm: () => alarm, + clearAlarm: () => { + alarm = null; + }, + }; + return storage; +} + +// put(key, value) with a non-string key is a caller bug in the runtime too. +function keyOrKeys(key) { + return String(key); +} + +function makeId(value, name) { + const text = String(value); + return { + name, + toString: () => text, + equals: (other) => String(other) === text, + }; +} + +/** + * @param {Function|undefined} DurableObjectClass - e.g. PollRoom from worker/index.js + * @param {() => Object} getEnv - the env the object is constructed with (read lazily, + * so the namespace can sit inside the env it is given) + */ +export function makeDurableObjectNamespace(DurableObjectClass, getEnv) { + const objects = new Map(); + + function objectFor(id) { + const key = String(id); + if (!objects.has(key)) { + if (typeof DurableObjectClass !== 'function') { + throw new Error('The Durable Object class is not exported from worker/index.js'); + } + const storage = makeStorage(); + const pending = []; + const state = { + id, + storage, + blockConcurrencyWhile: async (fn) => fn(), + waitUntil: (promise) => pending.push(promise), + }; + const instance = new DurableObjectClass(state, getEnv()); + objects.set(key, { id, state, storage, instance, pending }); + } + return objects.get(key); + } + + function stubFor(entry) { + const target = { + id: entry.id, + fetch: (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + return entry.instance.fetch(request); + }, + }; + return new Proxy(target, { + get(obj, prop) { + if (prop in obj) return obj[prop]; + if (prop === 'then') return undefined; + const member = entry.instance[prop]; + if (typeof member !== 'function') return undefined; + // RPC crosses a serialization boundary: nothing shared by reference. + return async (...args) => rpcClone(await member.apply(entry.instance, args.map(rpcClone))); + }, + }); + } + + const namespace = { + objects, + idFromName: (name) => makeId(`name:${name}`, name), + idFromString: (value) => makeId(value), + newUniqueId: () => makeId(crypto.randomBytes(32).toString('hex')), + get: (id) => stubFor(objectFor(id)), + getByName: (name) => namespace.get(namespace.idFromName(name)), + + /** Every object's storage, for tests that look for what must not be there. */ + allStorage: () => [...objects.values()].map((entry) => entry.storage.data), + + /** The earliest alarm any object has set, or null. */ + nextAlarm: () => { + const times = [...objects.values()].map((e) => e.storage.peekAlarm()).filter((t) => t !== null); + return times.length ? Math.min(...times) : null; + }, + + /** Every object's pending alarm, in ms. */ + alarms: () => [...objects.values()].map((e) => e.storage.peekAlarm()).filter((t) => t !== null), + + /** + * Fire every alarm whose time has come (by Date.now()), once each, the way + * the runtime would: the alarm is cleared before alarm() runs, so the + * handler may set the next one. + */ + async runDueAlarms(now = Date.now()) { + let fired = 0; + for (const entry of objects.values()) { + const at = entry.storage.peekAlarm(); + if (at === null || at > now) continue; + entry.storage.clearAlarm(); + if (typeof entry.instance.alarm === 'function') { + // eslint-disable-next-line no-await-in-loop + await entry.instance.alarm(); + } + // eslint-disable-next-line no-await-in-loop + await Promise.allSettled(entry.pending.splice(0)); + fired += 1; + } + return fired; + }, + + /** Settle anything an object handed to state.waitUntil. */ + async settle() { + await Promise.allSettled([...objects.values()].flatMap((e) => e.pending.splice(0))); + }, + }; + return namespace; +} + +/** A Map-backed KV with per-key metadata and expiry options recorded (never enforced). */ +export function makeKv(seed = {}) { + const store = new Map( + Object.entries(seed).map(([k, v]) => [k, { value: typeof v === 'string' ? v : JSON.stringify(v), metadata: null, options: null }]) + ); + const puts = []; + // get(key, 'json') and get(key, { type: 'json' }) are the same call. + const typeOf = (type) => (type && typeof type === 'object' ? type.type : type); + const read = (entry, type) => (typeOf(type) === 'json' ? JSON.parse(entry.value) : entry.value); + return { + store, + puts, + get: async (key, type) => { + const entry = store.get(key); + return entry === undefined ? null : read(entry, type); + }, + getWithMetadata: async (key, type) => { + const entry = store.get(key); + if (entry === undefined) return { value: null, metadata: null }; + return { value: read(entry, type), metadata: entry.metadata }; + }, + put: async (key, value, options) => { + puts.push({ key, value, options: options ?? null }); + store.set(key, { value: typeof value === 'string' ? value : String(value), metadata: options?.metadata ?? null, options: options ?? null }); + }, + delete: async (key) => { + store.delete(key); + }, + list: async ({ prefix = '' } = {}) => ({ + keys: [...store.entries()] + .filter(([k]) => k.startsWith(prefix)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, entry]) => ({ name, ...(entry.metadata ? { metadata: entry.metadata } : {}) })), + list_complete: true, + }), + }; +} + +/** An ExecutionContext that keeps its waitUntil promises. */ +export function makeCtx() { + const promises = []; + return { + waitUntil: (p) => promises.push(p), + passThroughOnException: () => {}, + settle: () => Promise.allSettled(promises.splice(0)), + }; +} + +/** An ASSETS binding that has nothing, so a request that falls through is visible as one. */ +export function makeEmptyAssets() { + const calls = []; + return { + calls, + fetch: async (request) => { + calls.push(new URL(request.url).pathname); + return new Response('not found', { status: 404, headers: { 'x-asset-path': new URL(request.url).pathname } }); + }, + }; +} diff --git a/worker/gating.test.js b/worker/gating.test.js index eb5cda5..718b519 100644 --- a/worker/gating.test.js +++ b/worker/gating.test.js @@ -232,3 +232,26 @@ describe('a revoked device', () => { expect((await listMeetings(listReq(dead), env)).status).toBe(200); }); }); + +/** + * Meeting polls (#80, AC-1): running a poll is Pro, voting never is. Through + * the Worker's front door, so the route and the gate are tested together. + */ +describe('meeting polls', () => { + const pollsReq = (uid) => + new Request('https://zoom.timer.simple-tech.app/api/polls', { + method: 'POST', + headers: { host: 'zoom.timer.simple-tech.app', 'content-type': 'application/json', ...bearer(uid) }, + body: JSON.stringify({ ballot: { categories: [{ role: 'Standard Speech', candidates: ['Alice', 'Bob'] }] } }), + }); + + it('refuses to open a poll on the free plan with the upgrade answer', async () => { + const { default: worker } = await import('./index.js'); + // An asset store that has nothing, so a request that misses the route is a 404, not a throw. + const ASSETS = { fetch: async () => new Response('not found', { status: 404 }) }; + const res = await worker.fetch(pollsReq('free'), { ...env, ASSETS, FLAGS_FORCE: '1' }, { waitUntil: () => {} }); + + expect(res.status).toBe(402); + expect(await res.json()).toMatchObject({ error: 'upgrade_required' }); + }); +}); diff --git a/worker/polls-zoom.test.js b/worker/polls-zoom.test.js new file mode 100644 index 0000000..e9e3077 --- /dev/null +++ b/worker/polls-zoom.test.js @@ -0,0 +1,452 @@ +import crypto from 'node:crypto'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import worker, * as workerModule from './index.js'; +import { mintSessionToken } from './session-token.js'; +import { entitlementKey } from './entitlements.js'; +import { encryptZoomContext } from './test-helpers.js'; +import { makeDurableObjectNamespace, makeKv, makeCtx, makeEmptyAssets } from './durable-object-fake.js'; + +/** + * The native Zoom poll (#80, part B, Q5): the host's app poll becomes a Zoom + * poll on a scheduled meeting, and its results are fetched after the meeting + * with a short-lived refresh token that only that poll's PollRoom ever holds, + * encrypted, and that is deleted on every path. + * + * Contract: `POST /api/polls/zoom` with a session and + * `{code, codeVerifier|verifier, meetingId, pollId}`; the owner's + * `GET /api/polls//results` carries the fetched answers as `zoom`. + * Zoom is faked at the fetch boundary, so every call it receives is visible. + */ + +const SIGNING_KEY = 'test-session-signing-key'; +const CLIENT_SECRET = 'test-zoom-client-secret'; +const WEBHOOK_SECRET = 'test-webhook-secret-token'; +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const START = Date.UTC(2026, 10, 15, 0, 30); +const MID = 'meeting-uuid-M=='; +const NUMERIC_ID = '123456789'; +const REFRESH_TOKEN = 'rt-original-secret-0123456789'; + +const BALLOT = { + categories: [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', candidates: ['Alice', 'Bob'] }, + { role: 'Standard Speech', label: 'Best Speaker', candidates: ['Dan', 'Erin'] }, + { role: 'Table Topics Evaluation', label: 'Best Evaluator', candidates: ['Frank', 'Gina'] }, + ], +}; + +/** What GET /past_meetings/{uuid}/polls answers: one row per participant. */ +const PAST_POLLS = { + id: Number(NUMERIC_ID), + questions: [ + { + name: 'Ann', + email: 'ann@example.com', + question_details: [ + { question: 'Best Table Topics Speaker', answer: 'Alice', polling_id: 'zoom-poll-1' }, + { question: 'Best Speaker', answer: 'Dan', polling_id: 'zoom-poll-1' }, + ], + }, + { + name: 'Ben', + email: 'ben@example.com', + question_details: [ + { question: 'Best Table Topics Speaker', answer: 'Alice', polling_id: 'zoom-poll-1' }, + { question: 'Best Speaker', answer: 'Erin', polling_id: 'zoom-poll-1' }, + ], + }, + { + name: 'Cy', + email: 'cy@example.com', + question_details: [{ question: 'Best Table Topics Speaker', answer: 'Bob', polling_id: 'zoom-poll-1' }], + }, + ], +}; + +let env; +let ctx; +let zoom; + +/** + * Zoom at the fetch boundary. `pastPolls` is the queue of answers the + * past-meeting polls endpoint gives, one per call: a status number, or a body. + */ +function fakeZoom({ meetingType = 2, uuid = 'aBcD12==', pastPolls = [PAST_POLLS] } = {}) { + const calls = []; + let refreshes = 0; + const queue = [...pastPolls]; + const reply = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + const fetchImpl = vi.fn(async (input, init = {}) => { + const request = input instanceof Request ? input : null; + const url = request ? request.url : String(input); + const method = (init.method ?? request?.method ?? 'GET').toUpperCase(); + const headers = new Headers(init.headers ?? request?.headers ?? {}); + let body = init.body ?? (request && method !== 'GET' ? await request.clone().text() : undefined); + if (body instanceof URLSearchParams) body = body.toString(); + const call = { url, method, authorization: headers.get('authorization'), body: typeof body === 'string' ? body : body == null ? '' : String(body) }; + calls.push(call); + + if (url.startsWith('https://zoom.us/oauth/token')) { + const form = new URLSearchParams(call.body || new URL(url).search); + if (form.get('grant_type') === 'authorization_code') { + return reply({ access_token: 'at-from-code', refresh_token: REFRESH_TOKEN, token_type: 'bearer', expires_in: 3600, scope: 'meeting:write:poll meeting:read:meeting meeting:read:list_poll_results' }); + } + if (form.get('grant_type') === 'refresh_token') { + refreshes += 1; + return reply({ access_token: `at-refreshed-${refreshes}`, refresh_token: `rt-rotated-${refreshes}`, token_type: 'bearer', expires_in: 3600 }); + } + return reply({ error: 'unsupported_grant_type' }, 400); + } + if (method === 'GET' && url === `https://api.zoom.us/v2/meetings/${NUMERIC_ID}`) { + return reply({ id: Number(NUMERIC_ID), uuid, type: meetingType, topic: 'Downtown Speakers' }); + } + if (method === 'POST' && url === `https://api.zoom.us/v2/meetings/${NUMERIC_ID}/polls`) { + return reply({ id: 'zoom-poll-1', status: 'notstart', ...JSON.parse(call.body || '{}') }, 201); + } + if (method === 'GET' && url.startsWith('https://api.zoom.us/v2/past_meetings/')) { + const next = queue.length ? queue.shift() : 404; + return typeof next === 'number' ? reply({ code: 3001, message: 'Meeting does not exist' }, next) : reply(next); + } + if (url.includes('posthog.com')) return reply({ status: 1 }); + return reply({ error: 'not faked' }, 404); + }); + + return { + fetchImpl, + calls, + tokenCalls: (grant) => calls.filter((c) => c.url.startsWith('https://zoom.us/oauth/token') && new URLSearchParams(c.body).get('grant_type') === grant), + pollCreates: () => calls.filter((c) => c.method === 'POST' && c.url.endsWith(`/meetings/${NUMERIC_ID}/polls`)), + pastCalls: () => calls.filter((c) => c.url.startsWith('https://api.zoom.us/v2/past_meetings/')), + }; +} + +function makeEnv() { + const next = { + PROFILES: makeKv(), + ASSETS: makeEmptyAssets(), + SESSION_SIGNING_KEY: SIGNING_KEY, + ZOOM_CLIENT_ID: 'test-client-id', + ZOOM_CLIENT_SECRET: CLIENT_SECRET, + ZOOM_WEBHOOK_SECRET_TOKEN: WEBHOOK_SECRET, + ZOOM_APP_HOME_URL: 'https://zoom.timer-dev.simple-tech.app', + WEB_ORIGIN: 'https://www.timer-dev.simple-tech.app', + ENTITLEMENT_ENFORCE: '1', + FLAGS_FORCE: '1', + }; + next.POLL_ROOM = makeDurableObjectNamespace(workerModule.PollRoom, () => next); + return next; +} + +const contextFor = (uid, mid = MID) => + encryptZoomContext({ ...(uid ? { uid } : {}), mid, typ: 'meeting', ts: Date.now(), exp: Date.now() + HOUR }, { secret: CLIENT_SECRET }); + +function call(method, path, { uid, body } = {}) { + const host = 'zoom.timer-dev.simple-tech.app'; + const headers = { host }; + if (body !== undefined) headers['content-type'] = 'application/json'; + if (uid) headers.authorization = `Bearer ${mintSessionToken(uid, SIGNING_KEY)}`; + return worker.fetch(new Request(`https://${host}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }), env, ctx); +} + +const readJson = async (res) => { + try { + return await res.json(); + } catch { + return null; + } +}; + +async function appPoll() { + await env.PROFILES.put(entitlementKey('host-uid'), JSON.stringify({ status: 'active', currentPeriodEnd: Date.now() + 365 * 24 * HOUR })); + const res = await call('POST', '/api/polls', { uid: 'host-uid', body: { ballot: BALLOT, context: contextFor('host-uid') } }); + expect(res.ok, `POST /api/polls answered ${res.status}`).toBe(true); + return (await res.json()).id; +} + +const createZoomPoll = (pollId, { uid = 'host-uid' } = {}) => + call('POST', '/api/polls/zoom', { + uid, + body: { code: 'auth-code-1', codeVerifier: 'verifier-1', verifier: 'verifier-1', meetingId: NUMERIC_ID, pollId }, + }); + +function signedWebhook(body) { + const raw = JSON.stringify(body); + const timestamp = String(Math.floor(Date.now() / 1000)); + const hash = crypto.createHmac('sha256', WEBHOOK_SECRET).update(`v0:${timestamp}:${raw}`).digest('hex'); + return worker.fetch( + new Request('https://www.timer-dev.simple-tech.app/api/zoom/webhook', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-zm-signature': `v0=${hash}`, 'x-zm-request-timestamp': timestamp }, + body: raw, + }), + env, + ctx + ); +} + +async function meetingEnded(uuid = 'aBcD12==') { + const res = await signedWebhook({ + event: 'meeting.ended', + event_ts: Date.now(), + payload: { account_id: 'acct-1', object: { id: NUMERIC_ID, uuid, host_id: 'host-uid', topic: 'Downtown Speakers', type: 2 } }, + }); + expect(res.status).toBe(200); + await ctx.settle(); + await env.POLL_ROOM.settle(); +} + +/** Move the clock and let every alarm that is due run. */ +async function advanceTo(at) { + vi.setSystemTime(at); + await env.POLL_ROOM.runDueAlarms(at); +} + +/** Everything the Worker has put anywhere it persists or prints. */ +function everythingPersisted() { + const kv = [...env.PROFILES.store.entries()].map(([k, v]) => `${k}=${v.value}`); + const rooms = env.POLL_ROOM.allStorage().map((data) => JSON.stringify([...data.entries()])); + const logs = ['log', 'info', 'warn', 'error'].flatMap((level) => console[level].mock.calls.map((args) => args.map(String).join(' '))); + return [...kv, ...rooms, ...logs].join('\n'); +} + +/** The count for an answer, wherever the stored results keep it. */ +function countFor(zoomResults, answer) { + let found = null; + const visit = (node) => { + if (!node || typeof node !== 'object') return; + if (typeof node[answer] === 'number') found = Math.max(found ?? 0, node[answer]); + const label = node.answer ?? node.name ?? node.candidate; + const n = node.count ?? node.votes; + if (label === answer && typeof n === 'number') found = Math.max(found ?? 0, n); + // Parallel arrays: {answers: ['Alice', 'Bob'], counts: [2, 1]}. + const values = Object.values(node); + for (const names of values) { + if (!Array.isArray(names) || !names.includes(answer)) continue; + for (const numbers of values) { + if (Array.isArray(numbers) && numbers.length === names.length && numbers.every((x) => typeof x === 'number')) { + found = Math.max(found ?? 0, numbers[names.indexOf(answer)]); + } + } + } + for (const value of Object.values(node)) visit(value); + }; + visit(zoomResults); + return found; +} + +beforeEach(() => { + vi.setSystemTime(START); + env = makeEnv(); + ctx = makeCtx(); + for (const level of ['log', 'info', 'warn', 'error']) vi.spyOn(console, level).mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- + +describe('POST /api/polls/zoom — creating the native poll', () => { + it('needs a signed-in user', async () => { + zoom = fakeZoom(); + vi.stubGlobal('fetch', zoom.fetchImpl); + const pollId = await appPoll(); + + expect((await createZoomPoll(pollId, { uid: null })).status).toBe(401); + expect(zoom.pollCreates()).toHaveLength(0); + }); + + // Zoom only allows polls on scheduled meetings; the host is told why. + it('refuses an instant meeting with a reason, and creates nothing', async () => { + zoom = fakeZoom({ meetingType: 1 }); + vi.stubGlobal('fetch', zoom.fetchImpl); + const pollId = await appPoll(); + + const res = await createZoomPoll(pollId); + const body = await readJson(res); + + expect(res.ok).toBe(false); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + expect(JSON.stringify(body)).toMatch(/instant/i); + expect(zoom.pollCreates()).toHaveLength(0); + }); + + it('creates one single-choice question per category on a scheduled meeting, with the candidates as answers', async () => { + zoom = fakeZoom(); + vi.stubGlobal('fetch', zoom.fetchImpl); + const pollId = await appPoll(); + + const res = await createZoomPoll(pollId); + expect(res.ok, `POST /api/polls/zoom answered ${res.status}`).toBe(true); + + const [exchange] = zoom.tokenCalls('authorization_code'); + const form = new URLSearchParams(exchange.body); + expect(form.get('code')).toBe('auth-code-1'); + expect(form.get('code_verifier')).toBe('verifier-1'); + + const [created] = zoom.pollCreates(); + expect(created.authorization).toBe('Bearer at-from-code'); + const { questions } = JSON.parse(created.body); + expect(questions).toHaveLength(3); + for (const [i, question] of questions.entries()) { + expect(question.type).toBe('single'); + expect(question.answers).toEqual(BALLOT.categories[i].candidates); + expect(question.name).toContain(BALLOT.categories[i].label); + } + }); + + it('keeps the host\'s refresh token only encrypted: never in KV, never plain in the room, never in a log', async () => { + zoom = fakeZoom(); + vi.stubGlobal('fetch', zoom.fetchImpl); + const pollId = await appPoll(); + expect((await createZoomPoll(pollId)).ok).toBe(true); + + const persisted = everythingPersisted(); + expect(persisted).not.toContain(REFRESH_TOKEN); + expect(persisted).not.toContain(Buffer.from(REFRESH_TOKEN).toString('base64')); + expect(persisted).not.toContain(Buffer.from(REFRESH_TOKEN).toString('hex')); + // The deadline that deletes it is already set: 24 hours at the latest. + expect(env.POLL_ROOM.nextAlarm()).not.toBeNull(); + expect(env.POLL_ROOM.nextAlarm()).toBeLessThanOrEqual(START + 24 * HOUR); + }); +}); + +describe('the results, after the meeting ends', () => { + async function nativePoll(options) { + zoom = fakeZoom(options); + vi.stubGlobal('fetch', zoom.fetchImpl); + const pollId = await appPoll(); + expect((await createZoomPoll(pollId)).ok).toBe(true); + return pollId; + } + + it('waits five minutes after meeting.ended, refreshes the token, fetches the answers and stores the counts', async () => { + const pollId = await nativePoll(); + const ended = Date.now() + 40 * MINUTE; + vi.setSystemTime(ended); + await meetingEnded(); + + // Zoom needs a few minutes to process the poll; nothing is asked before then. + expect(env.POLL_ROOM.nextAlarm()).toBeGreaterThanOrEqual(ended + 4 * MINUTE); + expect(env.POLL_ROOM.nextAlarm()).toBeLessThanOrEqual(ended + 6 * MINUTE); + await advanceTo(ended + 4 * MINUTE); + expect(zoom.pastCalls()).toHaveLength(0); + + await advanceTo(ended + 6 * MINUTE); + const [refresh] = zoom.tokenCalls('refresh_token'); + expect(new URLSearchParams(refresh.body).get('refresh_token')).toBe(REFRESH_TOKEN); + const [past] = zoom.pastCalls(); + expect(past.url.startsWith(`https://api.zoom.us/v2/past_meetings/${encodeURIComponent('aBcD12==')}/polls`)).toBe(true); + expect(past.authorization).toBe('Bearer at-refreshed-1'); + + const results = await readJson(await call('GET', `/api/polls/${pollId}/results`, { uid: 'host-uid' })); + expect(results?.zoom).toBeTruthy(); + expect(countFor(results.zoom, 'Alice')).toBe(2); + expect(countFor(results.zoom, 'Bob')).toBe(1); + expect(countFor(results.zoom, 'Dan')).toBe(1); + expect(countFor(results.zoom, 'Erin')).toBe(1); + // Counts only: who answered what stays with Zoom. + expect(JSON.stringify(results.zoom)).not.toContain('ann@example.com'); + }); + + it('deletes the token once the answers are in, rotated token included', async () => { + await nativePoll(); + await meetingEnded(); + await advanceTo(Date.now() + 6 * MINUTE); + expect(zoom.pastCalls()).toHaveLength(1); + + const persisted = everythingPersisted(); + expect(persisted).not.toContain(REFRESH_TOKEN); + expect(persisted).not.toContain('rt-rotated-1'); + + // Nothing left to refresh with: another meeting.ended asks Zoom for nothing. + await meetingEnded(); + await advanceTo(Date.now() + 10 * MINUTE); + await advanceTo(Date.now() + 2 * HOUR); + expect(zoom.tokenCalls('refresh_token')).toHaveLength(1); + expect(zoom.pastCalls()).toHaveLength(1); + }); + + it('retries an empty answer and a 404 at +15 and +60 minutes, then stores what arrives', async () => { + const pollId = await nativePoll({ pastPolls: [{ questions: [] }, 404, PAST_POLLS] }); + await meetingEnded(); + const first = Date.now() + 6 * MINUTE; + await advanceTo(first); + expect(zoom.pastCalls()).toHaveLength(1); + + // Not straight away. + await env.POLL_ROOM.runDueAlarms(); + expect(zoom.pastCalls()).toHaveLength(1); + + await advanceTo(first + 15 * MINUTE + 1000); + expect(zoom.pastCalls()).toHaveLength(2); + + await advanceTo(first + 15 * MINUTE + 60 * MINUTE + 2000); + expect(zoom.pastCalls()).toHaveLength(3); + + const results = await readJson(await call('GET', `/api/polls/${pollId}/results`, { uid: 'host-uid' })); + expect(countFor(results?.zoom, 'Alice')).toBe(2); + }); + + it('gives up after the last retry and deletes the token', async () => { + await nativePoll({ pastPolls: [404, 404, 404, 404, 404] }); + await meetingEnded(); + const first = Date.now() + 6 * MINUTE; + await advanceTo(first); + await advanceTo(first + 15 * MINUTE + 1000); + await advanceTo(first + 75 * MINUTE + 2000); + expect(zoom.pastCalls()).toHaveLength(3); + + await advanceTo(first + 6 * HOUR); + expect(zoom.pastCalls()).toHaveLength(3); + const refreshes = zoom.tokenCalls('refresh_token').length; + + await meetingEnded(); + await advanceTo(Date.now() + 10 * MINUTE); + expect(zoom.tokenCalls('refresh_token')).toHaveLength(refreshes); + expect(zoom.pastCalls()).toHaveLength(3); + }); + + it('deletes the token 24 hours after creation when the meeting never reports ending, and keeps the poll for its owner', async () => { + const pollId = await nativePoll(); + + await advanceTo(START + 24 * HOUR + MINUTE); + await advanceTo(START + 24 * HOUR + 2 * MINUTE); + expect(everythingPersisted()).not.toContain(REFRESH_TOKEN); + + await meetingEnded(); + await advanceTo(Date.now() + 10 * MINUTE); + expect(zoom.tokenCalls('refresh_token')).toHaveLength(0); + expect(zoom.pastCalls()).toHaveLength(0); + + // A native poll's room lives a week, so the owner can still collect it. + expect((await call('GET', `/api/polls/${pollId}/results`, { uid: 'host-uid' })).status).toBe(200); + }); + + // Zoom's rule for meeting UUIDs in a path: double-encode one that starts + // with "/" or contains "//". + it('double-encodes a meeting UUID that starts with a slash or contains a double slash', async () => { + const uuid = '/aB//cD=='; + await nativePoll({ uuid }); + await meetingEnded(uuid); + await advanceTo(Date.now() + 6 * MINUTE); + + const [past] = zoom.pastCalls(); + expect(past?.url.startsWith(`https://api.zoom.us/v2/past_meetings/${encodeURIComponent(encodeURIComponent(uuid))}/polls`)).toBe(true); + }); + + it('answers the webhook for a meeting with no native poll, and asks Zoom for nothing', async () => { + zoom = fakeZoom(); + vi.stubGlobal('fetch', zoom.fetchImpl); + await meetingEnded(); + await advanceTo(Date.now() + 10 * MINUTE); + expect(zoom.pastCalls()).toHaveLength(0); + expect(zoom.tokenCalls('refresh_token')).toHaveLength(0); + }); +}); diff --git a/worker/polls.test.js b/worker/polls.test.js new file mode 100644 index 0000000..d250e6e --- /dev/null +++ b/worker/polls.test.js @@ -0,0 +1,572 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import worker, * as workerModule from './index.js'; +import { FLAG_FALLBACKS } from './flags.js'; +import { isShareToken } from './club-share.js'; +import { mintSessionToken } from './session-token.js'; +import { entitlementKey } from './entitlements.js'; +import { encryptZoomContext } from './test-helpers.js'; +import { makeDurableObjectNamespace, makeKv, makeCtx, makeEmptyAssets } from './durable-object-fake.js'; + +/** + * Meeting polls (#80), from the Worker's side: one PollRoom Durable Object per + * poll holds the ballot and the votes, so the counts at Close are final. + * + * Contract these tests hold the implementation to: + * - worker/index.js exports the Durable Object class `PollRoom`; its binding + * is `env.POLL_ROOM`. Storage is the key-value API, not `storage.sql`. + * - a ballot is `{categories: [{role, label?, candidates: [name, …]}]}`; + * - a vote is `{choices: {: }, context?, device?}`; + * - results are `{counts, byPath: {zoom, guest, link}, status}`, where + * `counts[catIndex][candIndex]` is a number and `byPath.` has the same + * shape; close answers `{counts, byPath, winners}`, `winners[catIndex]` + * being the winning candidate names (every name tied at the top count, and + * none when the category got no votes). + */ + +const SIGNING_KEY = 'test-session-signing-key'; +const CLIENT_SECRET = 'test-zoom-client-secret'; +const WEB_ORIGIN = 'https://www.timer-dev.simple-tech.app'; +const ZOOM_HOST = 'zoom.timer-dev.simple-tech.app'; +const WWW_HOST = 'www.timer-dev.simple-tech.app'; +const HOUR = 60 * 60 * 1000; +const START = Date.UTC(2026, 10, 15, 0, 30); +const MEETING = 'meeting-uuid-M=='; +const OTHER_MEETING = 'meeting-uuid-N=='; + +const BALLOT = { + categories: [ + { role: 'Table Topics Speech', label: 'Best Table Topics Speaker', candidates: ['Alice', 'Bob', 'Carol'] }, + { role: 'Standard Speech', label: 'Best Speaker', candidates: ['Dan', 'Erin'] }, + { role: 'Table Topics Evaluation', label: 'Best Evaluator', candidates: ['Frank'] }, + ], +}; + +let env; +let ctx; + +function makeEnv() { + const next = { + PROFILES: makeKv(), + ASSETS: makeEmptyAssets(), + SESSION_SIGNING_KEY: SIGNING_KEY, + ZOOM_CLIENT_ID: 'test-client-id', + ZOOM_CLIENT_SECRET: CLIENT_SECRET, + ENTITLEMENT_ENFORCE: '1', + // Every flag on: these tests are about the endpoints, not the dark launch. + FLAGS_FORCE: '1', + WEB_ORIGIN, + }; + next.POLL_ROOM = makeDurableObjectNamespace(workerModule.PollRoom, () => next); + return next; +} + +/** A context for `uid` (null for a guest) in meeting `mid`, encrypted the way Zoom does it. */ +const contextFor = (uid, mid = MEETING) => + encryptZoomContext( + { ...(uid ? { uid } : {}), mid, typ: 'meeting', ts: Date.now(), exp: Date.now() + HOUR }, + { secret: CLIENT_SECRET } + ); + +async function makePro(uid) { + await env.PROFILES.put(entitlementKey(uid), JSON.stringify({ status: 'active', currentPeriodEnd: Date.now() + 365 * 24 * HOUR })); +} + +function call(method, path, { uid, body, host = ZOOM_HOST, ip = '203.0.113.7' } = {}) { + const headers = { host, 'cf-connecting-ip': ip }; + if (body !== undefined) headers['content-type'] = 'application/json'; + if (uid) headers.authorization = `Bearer ${mintSessionToken(uid, SIGNING_KEY)}`; + return worker.fetch( + new Request(`https://${host}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }), + env, + ctx + ); +} + +const readJson = async (res) => { + try { + return await res.json(); + } catch { + return null; + } +}; + +/** Create a poll as `owner` (a Pro uid), from inside meeting `mid` unless `mid` is null. */ +async function createPoll({ owner = 'owner-uid', mid = MEETING, ballot = BALLOT, expiryHours } = {}) { + const res = await call('POST', '/api/polls', { + uid: owner, + body: { ballot, ...(expiryHours ? { expiryHours } : {}), ...(mid ? { context: contextFor(owner, mid) } : {}) }, + }); + const body = await readJson(res); + expect(res.ok, `POST /api/polls answered ${res.status}`).toBe(true); + expect(isShareToken(body?.id)).toBe(true); + return body.id; +} + +const vote = (id, choices, { context, device, ip } = {}) => + call('POST', `/api/polls/${id}/votes`, { + ip, + body: { choices, ...(context !== undefined ? { context } : {}), ...(device !== undefined ? { device } : {}) }, + }); + +const results = async (id, uid = 'owner-uid') => readJson(await call('GET', `/api/polls/${id}/results`, { uid })); + +const count = (res, cat, cand) => Number(res?.counts?.[cat]?.[cand] ?? 0); + +/** Sum every number in a nested structure: the path split, however it is shaped. */ +function total(value) { + if (typeof value === 'number') return value; + if (Array.isArray(value)) return value.reduce((sum, v) => sum + total(v), 0); + if (value && typeof value === 'object') return Object.values(value).reduce((sum, v) => sum + total(v), 0); + return 0; +} + +beforeEach(() => { + vi.setSystemTime(START); + env = makeEnv(); + ctx = makeCtx(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- + +describe('what ships dark, and what it needs', () => { + it('declares the polls and polls_zoom release flags, both off by default', () => { + expect(FLAG_FALLBACKS.polls).toBe(false); + expect(FLAG_FALLBACKS.polls_zoom).toBe(false); + }); + + it('exports the PollRoom Durable Object class from the Worker entry point', () => { + expect(typeof workerModule.PollRoom).toBe('function'); + }); + + describe('wrangler.jsonc', () => { + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + // Comments in this file always sit on their own line (see flags.test.js). + const config = JSON.parse(readFileSync(join(repoRoot, 'wrangler.jsonc'), 'utf8').replace(/^\s*\/\/.*$/gm, '')); + const doBinding = (block) => (block?.durable_objects?.bindings ?? []).find((b) => b.name === 'POLL_ROOM'); + const limiter = (block) => (block?.unsafe?.bindings ?? []).find((b) => b.name === 'POLL_VOTE_LIMITER'); + const migrates = (migrations) => + (migrations ?? []).some((m) => [...(m.new_classes ?? []), ...(m.new_sqlite_classes ?? [])].includes('PollRoom')); + + // Bindings are not inherited by environments, so dev needs its own copy. + it('binds POLL_ROOM to the PollRoom class in production and dev', () => { + expect(doBinding(config)?.class_name).toBe('PollRoom'); + expect(doBinding(config.env.dev)?.class_name).toBe('PollRoom'); + }); + + it('has a migration that creates the PollRoom class', () => { + expect(migrates(config.migrations)).toBe(true); + expect(migrates(config.env.dev.migrations ?? config.migrations)).toBe(true); + }); + + it('limits link votes to 120 a minute in both environments', () => { + for (const block of [config, config.env.dev]) { + expect(limiter(block)?.type).toBe('ratelimit'); + expect(limiter(block)?.simple).toEqual({ limit: 120, period: 60 }); + } + }); + }); +}); + +describe('POST /api/polls — opening a poll', () => { + it('needs a signed-in user', async () => { + const res = await call('POST', '/api/polls', { body: { ballot: BALLOT } }); + expect(res.status).toBe(401); + }); + + // Running a poll is Pro; voting is not (see the vote tests below). + it('refuses a free plan with the upgrade answer', async () => { + const res = await call('POST', '/api/polls', { uid: 'free-uid', body: { ballot: BALLOT, context: contextFor('free-uid') } }); + expect(res.status).toBe(402); + expect((await readJson(res))?.error).toBe('upgrade_required'); + }); + + it('gives a Pro user an unguessable id and a voting link on the www host', async () => { + await makePro('owner-uid'); + const res = await call('POST', '/api/polls', { uid: 'owner-uid', body: { ballot: BALLOT, context: contextFor('owner-uid') } }); + const body = await readJson(res); + + expect(res.ok).toBe(true); + expect(isShareToken(body?.id)).toBe(true); + // Asked from the zoom. host, which routes every path to the sidebar app: + // the link has to point at the website instead. + expect(body?.url).toBe(`${WEB_ORIGIN}/vote/${body?.id}`); + }); + + it('refuses a ballot with no categories', async () => { + await makePro('owner-uid'); + const res = await call('POST', '/api/polls', { uid: 'owner-uid', body: { ballot: { categories: [] } } }); + expect(res.status).toBe(400); + }); +}); + +describe('GET /api/polls//ballot', () => { + beforeEach(() => makePro('owner-uid')); + + it('shows anyone the names and labels, and never a count', async () => { + const id = await createPoll(); + await vote(id, { 0: 0 }, { device: 'device-1' }); + + const res = await call('GET', `/api/polls/${id}/ballot`, { host: WWW_HOST }); + const text = JSON.stringify(await readJson(res)); + + expect(res.status).toBe(200); + for (const name of ['Alice', 'Bob', 'Carol', 'Dan', 'Erin', 'Frank', 'Best Speaker', 'Best Evaluator']) { + expect(text).toContain(name); + } + expect(text).not.toMatch(/"counts"|"byPath"/); + }); + + it('is a 404 for an id nobody minted, and for one that is not an id at all', async () => { + expect((await call('GET', '/api/polls/ABCDEFGHJKMNPQRS/ballot')).status).toBe(404); + expect((await call('GET', '/api/polls/not-an-id/ballot')).status).toBe(404); + }); + + it('expires after 24 hours by default, and the room deletes its storage', async () => { + const id = await createPoll(); + + vi.setSystemTime(START + 23 * HOUR); + await env.POLL_ROOM.runDueAlarms(); + expect((await call('GET', `/api/polls/${id}/ballot`)).status).toBe(200); + + vi.setSystemTime(START + 24 * HOUR + 60_000); + await env.POLL_ROOM.runDueAlarms(); + expect((await call('GET', `/api/polls/${id}/ballot`)).status).toBe(404); + expect(env.POLL_ROOM.allStorage().every((data) => data.size === 0)).toBe(true); + }); + + it('honours a shorter expiry', async () => { + const id = await createPoll({ expiryHours: 2 }); + vi.setSystemTime(START + 2 * HOUR + 60_000); + await env.POLL_ROOM.runDueAlarms(); + expect((await call('GET', `/api/polls/${id}/ballot`)).status).toBe(404); + }); +}); + +describe('POST /api/polls//votes — the web link', () => { + beforeEach(() => makePro('owner-uid')); + + it('takes a vote from a browser with no Zoom sign-in, counted under link', async () => { + const id = await createPoll(); + const res = await vote(id, { 0: 1 }, { device: 'device-1' }); + + expect(res.ok).toBe(true); + const r = await results(id); + expect(count(r, 0, 1)).toBe(1); + expect(total(r.byPath.link)).toBe(1); + expect(total(r.byPath.zoom)).toBe(0); + expect(total(r.byPath.guest)).toBe(0); + }); + + it('refuses a second vote from the same device in the same category, and the count stays put', async () => { + const id = await createPoll(); + expect((await vote(id, { 0: 1 }, { device: 'device-1' })).ok).toBe(true); + + const again = await vote(id, { 0: 2 }, { device: 'device-1' }); + expect(again.status).toBe(409); + + const r = await results(id); + expect(count(r, 0, 1)).toBe(1); + expect(count(r, 0, 2)).toBe(0); + }); + + it('lets the same device vote in a category it has not voted in yet', async () => { + const id = await createPoll(); + expect((await vote(id, { 0: 0 }, { device: 'device-1' })).ok).toBe(true); + expect((await vote(id, { 1: 1 }, { device: 'device-1' })).ok).toBe(true); + expect((await vote(id, { 0: 0 }, { device: 'device-2' })).ok).toBe(true); + + const r = await results(id); + expect(count(r, 0, 0)).toBe(2); + expect(count(r, 1, 1)).toBe(1); + }); + + it('refuses a choice that is not on the ballot', async () => { + const id = await createPoll(); + expect((await vote(id, { 0: 9 }, { device: 'device-1' })).status).toBe(400); + expect((await vote(id, { 7: 0 }, { device: 'device-2' })).status).toBe(400); + expect(total((await results(id)).counts)).toBe(0); + }); + + it('is a 404 for an unknown poll and for an expired one', async () => { + expect((await vote('ABCDEFGHJKMNPQRS', { 0: 0 }, { device: 'device-1' })).status).toBe(404); + + const id = await createPoll(); + vi.setSystemTime(START + 25 * HOUR); + await env.POLL_ROOM.runDueAlarms(); + expect((await vote(id, { 0: 0 }, { device: 'device-1' })).status).toBe(404); + }); + + it('refuses votes once the poll is closed', async () => { + const id = await createPoll(); + await vote(id, { 0: 0 }, { device: 'device-1' }); + expect((await call('POST', `/api/polls/${id}/close`, { uid: 'owner-uid' })).ok).toBe(true); + + const late = await vote(id, { 0: 1 }, { device: 'device-2' }); + expect(late.ok).toBe(false); + expect([403, 409, 410]).toContain(late.status); + const r = await results(id); + expect(count(r, 0, 0)).toBe(1); + expect(count(r, 0, 1)).toBe(0); + }); + + describe('the per-poll-and-address limiter', () => { + function makeLimiter(limit) { + const seen = new Map(); + return { + seen, + limit: vi.fn(async ({ key }) => { + const next = (seen.get(key) ?? 0) + 1; + seen.set(key, next); + return { success: next <= limit }; + }), + }; + } + + // One room's Wi-Fi is one address: a hybrid club of 120 has to fit. + it('takes 120 link votes a minute from one address, then answers 429', async () => { + env.POLL_VOTE_LIMITER = makeLimiter(120); + const id = await createPoll(); + + for (let i = 0; i < 120; i += 1) { + // eslint-disable-next-line no-await-in-loop + const res = await vote(id, { 0: i % 3 }, { device: `device-${i}`, ip: '198.51.100.1' }); + expect(res.ok, `vote ${i + 1} answered ${res.status}`).toBe(true); + } + const refused = await vote(id, { 0: 0 }, { device: 'device-120', ip: '198.51.100.1' }); + expect(refused.status).toBe(429); + expect(env.POLL_VOTE_LIMITER.seen.get(`${id}:198.51.100.1`)).toBe(121); + + // Another address is its own bucket. + expect((await vote(id, { 0: 0 }, { device: 'device-121', ip: '198.51.100.2' })).ok).toBe(true); + expect(total((await results(id)).counts)).toBe(121); + }); + + it('lets the vote through when the limiter itself fails', async () => { + env.POLL_VOTE_LIMITER = { limit: vi.fn(async () => { throw new Error('limiter down'); }) }; + const id = await createPoll(); + + expect((await vote(id, { 0: 0 }, { device: 'device-1' })).ok).toBe(true); + expect(env.POLL_VOTE_LIMITER.limit).toHaveBeenCalled(); + expect(count(await results(id), 0, 0)).toBe(1); + }); + + it('never applies to a vote cast inside Zoom', async () => { + env.POLL_VOTE_LIMITER = makeLimiter(0); + const id = await createPoll(); + + expect((await vote(id, { 0: 0 }, { context: contextFor('voter-uid') })).ok).toBe(true); + expect((await vote(id, { 0: 1 }, { context: contextFor(null), device: 'guest-device' })).ok).toBe(true); + expect(env.POLL_VOTE_LIMITER.limit).not.toHaveBeenCalled(); + }); + }); +}); + +describe('POST /api/polls//votes — inside Zoom', () => { + beforeEach(() => makePro('owner-uid')); + + // The voter has no plan at all, and enforcement is on: voting is free. + it('takes a signed-in participant\'s vote, counted under zoom', async () => { + const id = await createPoll(); + const res = await vote(id, { 1: 0 }, { context: contextFor('voter-uid') }); + + expect(res.ok).toBe(true); + const r = await results(id); + expect(count(r, 1, 0)).toBe(1); + expect(total(r.byPath.zoom)).toBe(1); + expect(total(r.byPath.link)).toBe(0); + }); + + it('keys a signed-in vote by the Zoom account, so a second device cannot vote again', async () => { + const id = await createPoll(); + expect((await vote(id, { 1: 0 }, { context: contextFor('voter-uid'), device: 'laptop' })).ok).toBe(true); + + const again = await vote(id, { 1: 1 }, { context: contextFor('voter-uid'), device: 'phone' }); + expect(again.status).toBe(409); + expect(count(await results(id), 1, 1)).toBe(0); + }); + + it('keys a guest\'s vote by device token, counted under guest', async () => { + const id = await createPoll(); + expect((await vote(id, { 0: 0 }, { context: contextFor(null), device: 'guest-1' })).ok).toBe(true); + expect((await vote(id, { 0: 1 }, { context: contextFor(null), device: 'guest-2' })).ok).toBe(true); + expect((await vote(id, { 0: 2 }, { context: contextFor(null), device: 'guest-1' })).status).toBe(409); + + const r = await results(id); + expect(total(r.byPath.guest)).toBe(2); + expect(count(r, 0, 2)).toBe(0); + }); + + it('refuses a context from another meeting with 403 wrong_meeting', async () => { + const id = await createPoll(); + const res = await vote(id, { 0: 0 }, { context: contextFor('voter-uid', OTHER_MEETING) }); + + expect(res.status).toBe(403); + expect((await readJson(res))?.error).toBe('wrong_meeting'); + expect(total((await results(id)).counts)).toBe(0); + }); + + it('refuses a context that does not decrypt with 400', async () => { + const id = await createPoll(); + const forged = encryptZoomContext({ uid: 'voter-uid', mid: MEETING, exp: Date.now() + HOUR }, { secret: 'not-our-secret' }); + + expect((await vote(id, { 0: 0 }, { context: forged })).status).toBe(400); + expect((await vote(id, { 0: 0 }, { context: 'garbage' })).status).toBe(400); + expect(total((await results(id)).counts)).toBe(0); + }); + + it('takes link votes only, for a poll opened with no meeting to tie it to', async () => { + const id = await createPoll({ mid: null }); + expect((await vote(id, { 0: 0 }, { device: 'device-1' })).ok).toBe(true); + expect((await vote(id, { 0: 0 }, { context: contextFor('voter-uid') })).status).toBe(403); + }); + + it('stores who voted only as hashes', async () => { + const id = await createPoll(); + await vote(id, { 0: 0 }, { context: contextFor('voter-uid-secret') }); + await vote(id, { 0: 1 }, { context: contextFor(null), device: 'guest-device-secret' }); + await vote(id, { 0: 2 }, { device: 'link-device-secret' }); + + const stored = JSON.stringify(env.POLL_ROOM.allStorage().map((data) => [...data.entries()])); + expect(stored).not.toContain('voter-uid-secret'); + expect(stored).not.toContain('guest-device-secret'); + expect(stored).not.toContain('link-device-secret'); + }); +}); + +describe('POST /api/polls/current — an invited app finds the poll', () => { + beforeEach(() => makePro('owner-uid')); + + const current = (context) => call('POST', '/api/polls/current', { body: { context } }); + + it('answers a participant in the same meeting with the open poll and its ballot', async () => { + const id = await createPoll(); + const res = await current(contextFor('voter-uid')); + const body = await readJson(res); + + expect(res.status).toBe(200); + expect(body?.id).toBe(id); + expect(JSON.stringify(body?.ballot)).toContain('Alice'); + expect(JSON.stringify(body)).not.toMatch(/"counts"|"byPath"/); + }); + + it('finds it for a guest too', async () => { + const id = await createPoll(); + expect((await readJson(await current(contextFor(null))))?.id).toBe(id); + }); + + it('is a 404 from another meeting, and once the poll is closed', async () => { + const id = await createPoll(); + expect((await current(contextFor('voter-uid', OTHER_MEETING))).status).toBe(404); + + await call('POST', `/api/polls/${id}/close`, { uid: 'owner-uid' }); + expect((await current(contextFor('voter-uid'))).status).toBe(404); + }); + + it('is a 404 for a poll opened with no meeting context', async () => { + await createPoll({ mid: null }); + expect((await current(contextFor('voter-uid'))).status).toBe(404); + }); + + it('refuses a context that does not decrypt', async () => { + await createPoll(); + const res = await current('garbage'); + expect([400, 404]).toContain(res.status); + }); +}); + +describe('results and Close belong to the poll\'s owner', () => { + beforeEach(async () => { + await makePro('owner-uid'); + await makePro('other-pro-uid'); + }); + + it('answers results to the owner only: 401 anonymous, 403 anyone else', async () => { + const id = await createPoll(); + expect((await call('GET', `/api/polls/${id}/results`)).status).toBe(401); + expect((await call('GET', `/api/polls/${id}/results`, { uid: 'other-pro-uid' })).status).toBe(403); + + const res = await call('GET', `/api/polls/${id}/results`, { uid: 'owner-uid' }); + expect(res.status).toBe(200); + expect((await readJson(res))?.status).toBe('open'); + }); + + it('splits the counts by how each vote came in', async () => { + const id = await createPoll(); + await vote(id, { 0: 0 }, { context: contextFor('voter-1') }); + await vote(id, { 0: 0 }, { context: contextFor('voter-2') }); + await vote(id, { 0: 1 }, { context: contextFor(null), device: 'guest-1' }); + await vote(id, { 0: 1 }, { device: 'link-1' }); + await vote(id, { 0: 1 }, { device: 'link-2' }); + await vote(id, { 0: 2 }, { device: 'link-3' }); + + const r = await results(id); + expect([count(r, 0, 0), count(r, 0, 1), count(r, 0, 2)]).toEqual([2, 3, 1]); + expect(total(r.byPath.zoom)).toBe(2); + expect(total(r.byPath.guest)).toBe(1); + expect(total(r.byPath.link)).toBe(3); + }); + + it('lets only the owner close it: 401 anonymous, 403 anyone else', async () => { + const id = await createPoll(); + expect((await call('POST', `/api/polls/${id}/close`)).status).toBe(401); + expect((await call('POST', `/api/polls/${id}/close`, { uid: 'other-pro-uid' })).status).toBe(403); + expect((await results(id)).status).toBe('open'); + }); + + it('closes with final counts and the winners: every name tied at the top, none for an empty category', async () => { + const id = await createPoll(); + await vote(id, { 0: 0 }, { device: 'd1' }); + await vote(id, { 0: 1 }, { device: 'd2' }); + await vote(id, { 0: 0 }, { device: 'd3' }); + await vote(id, { 0: 1 }, { device: 'd4' }); + await vote(id, { 0: 2 }, { device: 'd5' }); + await vote(id, { 1: 1 }, { device: 'd6' }); + + const res = await call('POST', `/api/polls/${id}/close`, { uid: 'owner-uid' }); + const body = await readJson(res); + + expect(res.status).toBe(200); + expect([count(body, 0, 0), count(body, 0, 1), count(body, 0, 2)]).toEqual([2, 2, 1]); + expect(body.winners[0]).toEqual(['Alice', 'Bob']); + expect(body.winners[1]).toEqual(['Erin']); + expect(body.winners[2] ?? []).toEqual([]); + expect(total(body.byPath.link)).toBe(6); + expect((await results(id)).status).toBe('closed'); + }); +}); + +describe('GET /vote/ — the backup voting page', () => { + beforeEach(() => makePro('owner-uid')); + + const page = (id) => call('GET', `/vote/${id}`, { host: WWW_HOST }); + + it('is Worker-rendered HTML on the www host, noindex, and never the SPA shell', async () => { + const id = await createPoll(); + const res = await page(id); + const html = await res.text(); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + expect(res.headers.get('x-robots-tag') ?? html).toMatch(/noindex/); + expect(env.ASSETS.calls).toEqual([]); + // Honest about what a device token can and cannot do. + expect(html).toMatch(/one vote per device/i); + }); + + it('is a real 404 for an id nobody minted', async () => { + const res = await page('ABCDEFGHJKMNPQRS'); + expect(res.status).toBe(404); + expect(env.ASSETS.calls).toEqual([]); + }); +}); diff --git a/worker/routing.test.js b/worker/routing.test.js index aedfd76..94a889e 100644 --- a/worker/routing.test.js +++ b/worker/routing.test.js @@ -1272,3 +1272,39 @@ describe('zoom launch marker', () => { expect(res.headers.get('content-security-policy')).toContain('appssdk.zoom.us'); }); }); + +// The backup voting link (#80). Worker-rendered HTML, like /r/: an +// unknown id is a real 404, never the SPA shell, and never indexed. +describe('/vote/', () => { + it('never serves the SPA shell, and answers an id nobody minted with a real 404', async () => { + const { PollRoom } = await import('./index.js'); + const { makeDurableObjectNamespace, makeKv: makeFullKv } = await import('./durable-object-fake.js'); + const env = { ...makeEnv(['/index.html', '/404.html']), PROFILES: makeFullKv() }; + env.POLL_ROOM = makeDurableObjectNamespace(PollRoom, () => env); + + const res = await worker.fetch(get('https://www.timer.simple-tech.app/vote/ABCDEFGHJKMNPQRS'), env, ctx); + + expect(res.status).toBe(404); + expect(res.headers.get('x-asset-path')).toBeNull(); + expect(env.ASSETS.fetch).not.toHaveBeenCalled(); + expect(res.headers.get('x-robots-tag') ?? (await res.text())).toMatch(/noindex/); + }); + + it('keeps the poll API reachable from the zoom. host, ahead of host routing', async () => { + const env = makeEnv(['/zoom/index.html']); + + const res = await worker.fetch( + new Request('https://zoom.timer.simple-tech.app/api/polls', { + method: 'POST', + headers: { host: 'zoom.timer.simple-tech.app', 'content-type': 'application/json' }, + body: JSON.stringify({ ballot: { categories: [] } }), + }), + env, + ctx + ); + + // No session: the poll handler's own answer, not the sidebar app's shell. + expect(res.status).toBe(401); + expect(env.ASSETS.fetch).not.toHaveBeenCalled(); + }); +}); From 378daf5b470cc3d18e09e2f3dedd3f365068ed4d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 01:48:49 +0000 Subject: [PATCH 4/8] feat(#80): shared poll logic, templates, archive and report image The ballot is built from the speakers this meeting already timed, so packages/shared/polls.js turns a template (club, personal or default) plus the reports into one meeting's ballot, and reads the winners back with ties kept and none for a category nobody voted in. The personal template syncs (SYNCED_KEYS); the club's copy arrives with refreshClub when ver moves; the draft and results stay local and go with Clear. Closed results ride the club outbox to /api/club/meetings//polls, and the report image grows a Winners block when there are any. Co-Authored-By: Claude Opus 5.5 --- packages/shared/club.js | 33 ++++ packages/shared/clubArchive.js | 49 +++++- packages/shared/index.js | 1 + packages/shared/polls.js | 267 ++++++++++++++++++++++++++++++++ packages/shared/profileMerge.js | 3 + packages/shared/reportImage.js | 38 ++++- packages/shared/storage.js | 3 + 7 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 packages/shared/polls.js 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); } From c91a5cb197911b0875e03cf66f14051a60f1402e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 01:48:49 +0000 Subject: [PATCH 5/8] feat(#80): poll endpoints, PollRoom Durable Object and the native Zoom poll One PollRoom per poll keeps strongly consistent counts, so the totals at Close are final, which KV could not promise. Votes inside Zoom are keyed by the verified uid from the app context and must come from the poll's meeting; guests and the /vote/ backup page are keyed by a hashed device token, the page behind a per-poll-and-address limit of 120 a minute that fails open. Results and Close are the owner's only. The club publishes a poll template like presets, and closed results are stored under their own archive key so compaction keeps them; /r/ lists the winners. The native Zoom poll keeps the host's refresh token only AES-GCM encrypted in its room and deletes it after the meeting.ended fetch, the last retry or 24 hours. Both flags are off. Co-Authored-By: Claude Opus 5.5 --- docs/CLUBS.md | 26 ++- docs/FEATURE_FLAGS.md | 18 +- docs/ZOOM_TEST_PLAN.md | 16 +- worker/auth.js | 14 +- worker/club-admin.js | 1 + worker/club-cards.js | 8 +- worker/club-meetings.js | 104 ++++++++- worker/club-poll-template.js | 103 +++++++++ worker/club-share.js | 31 ++- worker/club.js | 14 +- worker/flags.js | 9 + worker/index.js | 17 ++ worker/poll-room.js | 245 +++++++++++++++++++++ worker/polls-zoom.js | 231 ++++++++++++++++++++ worker/polls.js | 398 +++++++++++++++++++++++++++++++++++ worker/zoom-webhook.js | 9 + wrangler.jsonc | 29 +++ 17 files changed, 1246 insertions(+), 27 deletions(-) create mode 100644 worker/club-poll-template.js create mode 100644 worker/poll-room.js create mode 100644 worker/polls-zoom.js create mode 100644 worker/polls.js 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/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-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-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)}