Skip to content

Meeting polls: vote for Best Table Topics Speaker, Best Speaker, and Best Evaluator (#80) - #143

Draft
claude[bot] wants to merge 8 commits into
devfrom
factory/80-meeting-polls
Draft

claude[bot] wants to merge 8 commits into
devfrom
factory/80-meeting-polls

Conversation

@claude

@claude claude Bot commented Nov 14, 2026

Copy link
Copy Markdown

Closes #80

Plan: .factory/plans/80-meeting-polls.md

Answer the open questions inline on the plan file, then add plan:approved.

🤖 Generated with Claude Code

…eaker, and Best Evaluator

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude claude Bot added the plan:review Factory: plan is up; answer its open questions, then add plan:approved label Nov 14, 2026
@claude

claude Bot commented Nov 14, 2026

Copy link
Copy Markdown
Author

plan — The plan ships web-link voting (A) and the native Zoom poll (B) in one PR, built in layers, as the issue requires.

  1. Shared ballot logic in packages/shared/polls.js: the default three-category ballot, candidates auto-filled from this meeting's reports by role, and tie-aware winners.
  2. Worker /api/polls: a Durable Object per poll keeps one vote per device per category, with exact counts at Close. Results can be read and closed only by the poll's owner. A 24 h expiry deletes the poll. A Worker-rendered /vote/<id> page on the www. host takes votes and has no sign-in.
  3. Club poll template: an admin or editor publishes it with PUT /api/club/poll-template, which bumps ver, the same flow as presets. A Pro user without a club gets a personal template, synced through SYNCED_KEYS.
  4. Results: stored under the club meeting in their own key, so archive compaction doesn't drop them.
  5. POLL tab in the Zoom app: the setup, Open (with sendMessageToChat as an optional capability, plus Copy link), live counts, Close & reveal, and an optional winners panel on the Stage.
  6. Native Zoom poll (B): for the host only. zoomSdk.authorize with PKCE runs, then the Worker creates one question per category through Zoom's REST API. It refuses instant meetings and stores no token.

Everything is behind the new flags polls and polls_zoom, both off by default. Risk is high: public vote endpoints, a new DO binding in wrangler.jsonc, and new Zoom scopes plus a Marketplace review for B.

Open questions (defaults in brackets):

  • Q1: Durable Object vs KV for votes [DO]
  • Q2: Zoom app only, web app in a follow-up [yes]
  • Q3: per-IP vote rate limiter [yes, 30/min]
  • Q4: someone must add meeting:write:poll and meeting:read:meeting in the Marketplace and submit for review. Until then B stays dark and its Zoom steps are ⏭ [as stated]
  • Q5: fetch native poll results after the meeting [no, follow-up]
  • Q6: winners on the report PNG and the /r/ page [no, follow-up]
  • Q7: the poll owner is the creating user [yes]
  • Q8 (proposed): a category with zero votes has no winner [yes]

@ShuhaoZQGG

Copy link
Copy Markdown
Contributor

Review answers (Q5, Q6). #80 isn't split, so both are in this PR, not follow-ups.

  • Q5: yes, in this PR. Fetch the native poll's results after the meeting (GET /past_meetings/{id}/polls) and attach them to the meeting's results. The plan has to cover what this adds:
    • the stored refresh token: where it lives, how it's protected, and when it's deleted;
    • the extra read scope, added to Q4's list for the Marketplace review;
    • what triggers the fetch, given Zoom's processing delay after the meeting ends.
  • Q6: yes, in this PR. The report image (packages/shared/reportImage.js) and the shared /r/ page show each category's winners.

Q1–Q4, Q7 and Q8 are still open.

@ShuhaoZQGG

Copy link
Copy Markdown
Contributor

Review answers (Q1, Q2, Q4, Q5, Q6, Q7, Q8). Q3 is still open: I'm looking at verified Zoom identities for votes in place of an IP limit. A separate comment will follow.

  • Q1: Durable Object. One PollRoom per poll, as planned.
  • Q2: Zoom app only. The web app's POLL tab isn't in this PR.
  • Q4: I'm adding the Marketplace changes to the dev app myself. sendMessageToChat is already added. Coming: the scopes meeting:write:poll, meeting:read:meeting and the past-poll-results read scope (Q5); the SDK API getMeetingContext (the numeric meeting ID); and the authorize APIs, if the dev app still refuses them. Run MP-04, MP-13 and MP-14 for real once they're in. Use the ⏭ default only for any step whose setting still isn't there at the Zoom run.
  • Q5: store the host's refresh token for a short time. Encrypted at rest, deleted right after the results are fetched, or after 24 h at the latest. The fetch starts from the meeting.ended webhook (already handled in zoom-webhook.js), after a delay for Zoom's processing, and attaches the results to the meeting's results. Update the Risk section and AC-13 ("No Zoom token is stored" no longer holds).
  • Q6: yes. The report image and the /r/ page show each category's winners.
  • Q7: the app user who creates the poll owns it (uid), as planned.
  • Q8: the default. A category with no votes shows "No votes" and has no winner.

@ShuhaoZQGG

Copy link
Copy Markdown
Contributor

Review answer (Q3): vote inside Zoom first, with the web link as a backup. This changes the Approach, so update the plan before building.

  • Primary path, in Zoom: the poll runner taps "Invite everyone" (sendAppInvitationToAllParticipants). Each participant opens Toastmusters Timer in their Zoom client and votes in the app.
    • Identity: the vote carries the voter's Zoom app context (getAppContext()), and the Worker decrypts it with decryptAppContext (worker/zoom-context.js). The voter's verified uid is the de-dupe key: one vote per Zoom account per category.
    • Same meeting only: the voter's mid must match the meeting the poll was created in. Record the owner's mid when the poll is created.
    • Finding the poll: the invite can't carry data, so the participant's app has to find the open poll some other way, for example by looking it up by the voter's verified mid.
    • No plan needed to vote: running a poll is Pro only, but casting a vote isn't. The voting view must not be behind the upgrade prompt.
    • Guests with no uid (not signed into Zoom) can still vote in the app, keyed by a device token. Record each vote's path (zoom / guest / link) so the weaker ones can be counted separately.
  • Backup path, the web link: stays as planned (/vote/<id>, device token) for in-person members, people the invite doesn't reach (waiting room, a failed invite) and phone joiners who can use a browser. Its rate limit applies to this path only: per poll and IP, about 120 per minute, and voting stays open if the limiter itself fails. Thirty per minute would refuse a hybrid club voting from one room's Wi-Fi.
  • Test the invite flow early: it's unconfirmed whether a participant who hasn't added the app can open it from the invite in one click. The unpublished dev app needs enableGuestModeTesting for guests to work. Add Zoom-client steps for the invite and an in-Zoom vote from a second participant (a human step).
  • Marketplace: I'm also adding sendAppInvitationToAllParticipants to the dev app's Zoom App SDK APIs (along with the Q4 list).

@ShuhaoZQGG

Copy link
Copy Markdown
Contributor

Dev app Marketplace changes are in (follow-up to Q3/Q4). The dev Zoom app now has:

  • scopes: meeting:write:poll, meeting:read:meeting (check it's present), and meeting:read:list_poll_results;
  • Zoom App SDK APIs: sendMessageToChat, getMeetingContext, sendAppInvitationToAllParticipants, authorize, onAuthorized and promptAuthorize;
  • event subscriptions: meeting.started and meeting.ended to https://www.timer-dev.simple-tech.app/api/zoom/webhook (the dev Worker has ZOOM_WEBHOOK_SECRET_TOKEN);
  • Guest Mode Testing turned on.

So run the chat post (MP-04), the native poll (MP-13 and MP-14) and the new invite and in-Zoom vote steps for real, with no ⏭ for missing settings. Before the run, check the Debug Panel's SDK Status (as in CC-00) to confirm the new APIs show as granted.

Guests on the unpublished dev app also need a local flag on each device that joins as a guest: on macOS, defaults write ZoomChat enableGuestModeTesting true, then restart Zoom. Put this in the spec's setup for the guest participant.

@ShuhaoZQGG ShuhaoZQGG added the plan:approved Factory: plan approved; oracle, implement and verify run label Nov 15, 2026
@claude

claude Bot commented Nov 15, 2026

Copy link
Copy Markdown
Author

build — Stopped before starting: this run's permissions refuse every node/npm command (node --version, npm --version and node scripts/factory/state.mjs 80 were all denied), so the oracle, implement and verify sub-stations couldn't run tests, npm run test:specs or the state check. Nothing was changed: no commits, plan:review and plan:approved are still on the PR, and the review answers (Q1–Q8, including the Q3 change to the Approach) are still waiting to go into the plan. First failure of this station; rerun /factory-build 80 once node/npm are allowed for Bash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude claude Bot removed the plan:review Factory: plan is up; answer its open questions, then add plan:approved label Nov 15, 2026
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 <noreply@anthropic.com>
@claude

claude Bot commented Nov 15, 2026

Copy link
Copy Markdown
Author

oracle — 26933dc writes failing tests for AC-1…AC-17 and records them in .factory/oracles/80.json (17 files). The implementer may not edit them. Every new test fails on an assertion: no import errors, no TypeErrors. Each new module is loaded through a variable dynamic import, so a missing module fails its tests one at a time. The existing tests in the changed files still pass, and npm run test:specs is green.

Contract the tests fix (the plan left these open):

  • Durable Object: worker/index.js exports PollRoom, bound as env.POLL_ROOM. It uses storage's key-value API (get/put/delete/list/alarm), not storage.sql. worker/durable-object-fake.js runs the real class in memory and answers both stub.fetch and RPC. wrangler.jsonc binds it in prod and dev, has a migration for it, and POLL_VOTE_LIMITER is {limit: 120, period: 60} in both.
  • Shapes:
    • A ballot is {categories: [{role, label?, candidates}]}. A vote is {choices: {cat: cand}, context?, device?}.
    • Results are counts[cat][cand] plus byPath.{zoom,guest,link} in the same shape. Close also returns winners[cat] as names.
    • POST /api/polls returns {id, url}, and url is ${WEB_ORIGIN}/vote/<id>.
  • Shared:
    • packages/shared/polls.js exports defaultBallot, ballotFromTemplate, normalizeTemplate, winners (takes {name: count}) and activePollTemplate.
    • clubArchive.recordPollResults() posts to /api/club/meetings/<id>/polls.
    • renderReportPng({…, polls}) draws the Winners block.
    • TimerStage takes a pollWinners prop.
  • Zoom app: wrappers in utils/zoomSdk: readMeetingContext, sendChatMessage, inviteAllParticipants, plus the existing readAppContext, readZoomUserSummary and requestZoomAuthorizeCode. The accessible names are listed in the header of PollTab.test.jsx. The app calls POST /api/polls/current even with no context.

How they fail now:

  • worker/polls.test.js (40): the /api/polls* routes answer 404 or fall through to the asset store; PollRoom and the flags are undefined; wrangler.jsonc has no binding or limiter. One guard passes already (an unknown id is 404).
  • worker/polls-zoom.test.js (11): no poll can be created, so there is no /api/polls/zoom and meeting.ended sets no alarm. The no-poll webhook case passes.
  • worker/club-poll-template.test.js (5): PUT /api/club/poll-template returns 404, and the state has no pollTemplate.
  • club-meetings.test.js (+4): …/polls returns 404, and meeting.polls is undefined.
  • club-share.test.js (+4, 3 failing): /r/ and renderSharePage show no winners. The "exactly as before" case passes.
  • routing.test.js (+2): /vote/<id> falls through to the asset store; /api/polls on the zoom. host gets the shell instead of 401.
  • csp.test.js (+1) and gating.test.js (+1): POST /api/polls returns 404 instead of 201 or 402.
  • packages/shared/__tests__/polls.test.js (17): polls.js is missing, and refreshClub does not save toastmaster_club_poll_template.
  • profileMerge.test.js (+2, 1 failing): toastmaster_poll_template is not in SYNCED_KEYS.
  • reportImage.test.js (+3, 2 failing): no Winners text, and the image height does not grow.
  • clubArchive.test.js (+4): recordPollResults is undefined.
  • zoomSdk.test.js (+1): the three APIs are missing from USED_SDK_APIS.
  • PollTab.test.jsx (23): components/PollTab.jsx is missing. I checked the harness against a throwaway stub: it renders with the real TimerProvider and the mocks.
  • TimerStage.test.jsx (3, 2 failing): there is no winners panel.
  • e2e/zoom.meeting-polls.spec.js (10, 8 failing): there is no POLL tab, vote banner or ballot, and the in-process Worker answers POST /api/polls with 404. The "flag off" and "unknown id 404" cases pass.

Zoom-client: a new spec, e2e/zoom-client/meeting-polls.md (MP-01…MP-19), with status: stale and verified: never, listed in INDEX.md after club-card-sets. Its covers lists only files that exist today; add PollTab.jsx, PollVote.jsx, worker/polls.js and packages/shared/polls.js once they land.

Not covered here: I did not add PollVote.test.jsx, club.test.js or zoom-webhook.test.js. Their checks are in PollTab.test.jsx (voting), club-poll-template.test.js (state) and polls-zoom.test.js (meeting.ended → DO) instead.

github-actions Bot and others added 4 commits November 15, 2026 01:48
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/<id>/polls,
and the report image grows a Winners block when there are any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m 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/<id> 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 <noreply@anthropic.com>
The organizer sets up, opens, watches and closes the poll from a POLL
tab after REPORT (flag polls); a free plan sees the setup locked behind
Upgrade but can always vote. Opening posts the link to chat and offers
"Invite everyone", both optional SDK capabilities with Copy link as the
fallback. An invited participant's app finds the open poll through its
app context and shows "Vote now". Close reveals the winners, keeps them
for the Report tab and the club archive, and "Show on Stage" puts them
on the owner's Stage. The meeting-polls spec now covers the new files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and timer-sounds for #80

Meeting polls touched files these specs cover (LiveTab.jsx passes poll
winners to the Stage, zoomSdk.js gained three optional APIs, club.js
stores the poll template, club-cards.js shares its publisher check). No
step changes; timer-core and club-card-sets are marked stale until the
Zoom station reruns them. verified_at stays at the last real run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Nov 15, 2026

Copy link
Copy Markdown
Author

implement — Three feature commits make the oracle green: 378daf5 (shared), c91a5cb (Worker), 4fa68d7 (Zoom app), plus d5fcaa1, which reviews the specs. npm test passes (2397 tests), npm run test:e2e passes (54), and npm run test:specs is valid.

  • Shared: packages/shared/polls.js builds the ballot, normalizes templates, picks winners and keeps the stage-winners store. Also changed: SYNCED_KEYS, refreshClub (saves the club template), recordPollResults (same outbox as speeches) and the report image's Winners block.
  • Worker:
    • worker/polls.js holds the routes and the /vote/<id> page. worker/poll-room.js is the PollRoom DO, a plain class reached through stub.fetch.
    • worker/polls-zoom.js runs the native poll: encrypted refresh token, meeting.ended alarm, retries.
    • worker/club-poll-template.js publishes the club template.
    • Archive key and /r/ winners are in club-meetings.js and club-share.js. wrangler.jsonc has the binding, the migration and POLL_VOTE_LIMITER. worker/flags.js adds polls and polls_zoom.
    • Docs updated: docs/CLUBS.md, docs/FEATURE_FLAGS.md, docs/ZOOM_TEST_PLAN.md.
  • Zoom app: a POLL tab after REPORT, behind polls (PollTab.jsx), and an any-plan ballot plus a "Vote now" banner (PollVote.jsx). Three optional SDK wrappers were added to zoomSdk.js. The Stage gets a winners panel, and the Report tab gets a winners card plus the native results merge.
  • Specs:
    • meeting-polls now lists the new files under covers.
    • timer-core and club-card-sets are marked stale for a rerun. No step changed.
    • contact-capture and timer-sounds got a reviewed note.

One departure from the plan: the plan says the rotated refresh token is never stored beyond that one call. I believe Zoom invalidates the old token on refresh (not checked against Zoom's docs), and if so the +15 and +60 min retries could not work with the original. So while a retry is pending, the rotated token replaces the stored one, encrypted the same way. It is still deleted once the answers are in, after the last retry, or at 24 h. The plan's Risk section should say so, or the change should be reverted if that isn't wanted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

plan:approved Factory: plan approved; oracle, implement and verify run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant