Polls - Developer Guide
This guide covers the schema, row-level security, triggers, and data flow behind the Live Polls feature. Every claim below is cited to a file and line inpawtograder/platform. For the instructor and student view of the same feature, see Creating polls and Answering polls.
Overview
A poll is one row inlive_polls whose question column holds a SurveyJS element list. Students render it with SurveyJS on a standalone page, staff watch answers arrive over a Supabase realtime broadcast channel, and Recharts draws the bars.
Two design decisions shape everything else on this page:
- The poll link is scoped to the course, not the poll.
/poll/<course_id>resolves whichever poll is newest and live at request time (app/poll/[course_id]/page.tsx:38-45). - Only two SurveyJS question types are charted. Everything else stores and collects fine but has no reader (
app/course/[course_id]/manage/polls/[poll_id]/responses/PollResponsesDynamicViewer.tsx:31-40).
Architecture
Tech stack
Key dependencies
Frompackage.json:
There is no
qrcode package. usePollQrCode calls qrcodegen.QrCode.encodeText directly, builds the SVG string itself, and returns it as a data:image/svg+xml URL (hooks/usePollQrCode.tsx:6-21, :28-52). It memoizes nothing and regenerates on every render.Database schema
Both poll tables were created insupabase/migrations/20251213200333_surveys_polls.sql.
live_polls
20251213200333_surveys_polls.sql:286-295. Foreign keys: class_id to classes(id) and created_by to user_roles(public_profile_id), both ON DELETE CASCADE (:429-435).
Field notes:
questionholds the SurveyJS element list. See Question JSON.is_liveis the only flag any read path consults. It gates the anonymousSELECTpolicy (20260817120000_tighten_survey_and_poll_rls.sql:160), the student hook (hooks/useCourseController.tsx:2482), and the public page’s query (app/poll/[course_id]/page.tsx:42).deactivates_atis written on every open and cleared on every close, and then never read. See Expiry is not wired up.require_logindecides whether responses must carry a profile id. It is enforced only insidecan_access_poll_response.created_byhas no default and no trigger. The client must supply its ownpublic_profile_id(app/course/[course_id]/manage/polls/new/page.tsx:196), and the insert policy checks it withauthorizeforprofile(created_by)(20251213200333_surveys_polls.sql:1131).
created_by for a poll is a public profile id. Surveys use the private one (app/course/[course_id]/manage/surveys/new/page.tsx:333). Do not copy an id from one feature to the other.live_poll_responses
20251213200333_surveys_polls.sql:273-281, with UNIQUE (live_poll_id, public_profile_id) at :391 and :427. live_poll_id cascades on delete (:419), which is what makes a poll deletion take its responses with it.
submitted_at is stamped by a trigger the first time is_submitted flips to true (:700-711).
Question JSON
The schema default is'[]'::jsonb, but no code path stores an array. Every writer stores an object with an elements key, and the create form rejects anything else:
app/course/[course_id]/manage/polls/new/page.tsx:21-27). Its validator requires a non-null object, a non-empty elements array, and type plus title on elements[0] (:72-99). Nothing validates elements after the first.
At render time the standalone page names any unnamed element poll_question_<index>, defaults isRequired to true, and wraps the list in a single SurveyJS page (app/poll/[course_id]/page.tsx:108-125). So responses come back keyed by position:
checkbox question yields an array at the same key.
Charted question types
PollBuilder offers exactly two types, mapped from internal names to SurveyJS names in QUESTION_TYPE_REGISTRY (components/PollBuilder.tsx:30-41):
You can hand-write any other SurveyJS type into the JSON textarea and it will save, publish, and collect responses. The results viewer will not read it:
parseJsonForType returns undefined for anything but radiogroup and checkbox, logs Unsupported poll question type in SurveyJS JSON, and the panel renders Unsupported poll question type: <type> (PollResponsesDynamicViewer.tsx:27-40, :253).
Triggers
All five live in20251213200333_surveys_polls.sql:1325-1333.
set_poll_deactivates_at_trigger is BEFORE UPDATE only. A poll created already-live gets its deactivates_at from the client instead (app/course/[course_id]/manage/polls/new/page.tsx:187-190), and PollsTable sends the same value again when toggling (PollsTable.tsx:67-74) even though the trigger would overwrite it.created_by. The only trigger touching it forbids changing it after insert.
Expiry is not wired up
set_poll_deactivates_at() stamps deactivates_at an hour ahead, an index exists for scanning it (20251213200333_surveys_polls.sql:371), and a function exists to act on it:
20251213200333_surveys_polls.sql:670-685.
Row-level security
Both tables have RLS enabled (20251213200333_surveys_polls.sql:284, :298). Policy names do not follow the table name, so grep for the literal string rather than guessing.
live_polls
The original
live_polls_select was TO anon, authenticated USING (true), which exposed every column of every poll in every course without authenticating (20251213200333:1143-1148). 20260817120000_tighten_survey_and_poll_rls.sql:153 drops it and splits it in two. The migration’s header (:51-70) records why the staff replacement is scoped to graders rather than class members: permissive policies are ORed, so a class-member predicate would hand enrolled students back the draft and closed rows the change exists to withhold.
A live poll’s question remains readable by any unauthenticated visitor who knows the course id. That is inherent to joining by QR code with no token in the URL (
20260817120000:68-70).live_poll_responses
There is no student
SELECT policy on live_poll_responses. A student can write a response and never read it back, which is why the public page detects a repeat submission from the unique-violation SQLSTATE rather than by querying (app/poll/[course_id]/page.tsx:147).
can_access_poll_response
20251213200333_surveys_polls.sql:611-635. Three properties matter when you change anything on the write path:
- It never reads
is_live. A page loaded while the poll was open can still submit after it closes. On arequire_login = falsepoll, a caller who knows a poll’sidcan insert against a poll that was never live at all. require_login = falsereturnstrueunconditionally. Not “true whenprofile_id IS NULL”. Any caller may insert with anypublic_profile_id, including another student’s, and the row will look attributed.require_login = trueis the only branch that checks anything, and it checks both class membership and profile ownership, so impersonation is blocked there.
Duplicate responses
UNIQUE (live_poll_id, public_profile_id) dedupes authenticated respondents. PostgreSQL treats NULL values as distinct in a unique index, so it does not dedupe anonymous ones.
Real-time synchronization
Channels
broadcast_live_poll_change() sends to two class-wide topics, both constructed inside the trigger (20260817120000:231, :252). The names match the topics ClassRealTimeController subscribes to (lib/ClassRealTimeController.ts:251, :269).
There is a per-user topic in the controller (
lib/ClassRealTimeController.ts:285), but polls do not use it.
Asymmetric payloads
The broadcast function isSECURITY DEFINER and channel membership is the only authorization on that path, so the trigger has to do the filtering that RLS cannot. It computes row_is_live and branches (20260817120000:216, :235-247):
TableController copies data straight into its cache when present and refetches through RLS when it is absent, so a data-free DELETE makes the client drop the row by id and learn nothing else. The migration’s comment at 20260817120000:169-191 explains the failure this fixes: before it, any student holding an open session while staff drafted a poll received the question JSON regardless of what the SELECT policy said.
Responses broadcast to the staff channel only (20251213200333:593-598). Students never receive them.
React hooks
All four poll hooks live inhooks/useCourseController.tsx and read through the course controller’s livePolls TableController.
usePollResponseCounts seeds one bucket per choice at zero, fetches existing rows once, then subscribes to live_poll_responses INSERTs on the class realtime controller and increments in place (:2580-2636). It tracks seen response ids in a ref to avoid double counting a row that arrives during the initial fetch, and skips any answer starting with other:. Because it only ever increments, a deleted response does not decrement the chart until the page reloads.
File layout
Poll components are co-located with their routes. There is nocomponents/polls/ directory.
PollsTable’s action menu offers only view responses and delete, and delete is a hard delete via livePolls.hardDelete (PollsTable.tsx:107), which the ON DELETE CASCADE on live_poll_responses.live_poll_id extends to the responses.
TypeScript types
Testing
Poll E2E tests are intests/e2e/polls.test.tsx, 16 active tests in one test.describe("Polls") block (:50).
:93), a poll going live without a refresh (:103), closed polls staying hidden from students (:227), only the newest live poll reaching the student page (:244), the staff filter tabs (:189), the visual builder writing back JSON (:173), the live toggle from both the table (:311) and the responses page (:423), an anonymous visitor being sent to log in for a require_login poll (:353), a stored student submission (:386), and the public page’s empty state (:460).
No test asserts on live count updates. The one delete test is test.skipped (:467), so nothing covers the cascade either.
Troubleshooting
Poll not visible to students
- Check
is_live. It is the only field any student read path filters on. - Confirm the student is enrolled in
class_id.live_polls_select_livedoes not check enrollment, but the student page and hook are both scoped to a course. - If more than one poll is live, remember that
/poll/<course_id>serves only the newest bycreated_at(app/poll/[course_id]/page.tsx:43-45). Close the others. - Do not investigate
deactivates_at. Nothing reads it.
Real-time updates not arriving
- Confirm the client joined the right topic. Students are on
class:{class_id}:students, not a per-user topic. - Check whether the poll is live. A student receiving a data-free
DELETEwhere you expected anUPDATEis the intended behavior for a poll that is not live (20260817120000:235-247). - Check that
broadcast_live_polls_realtimeandbroadcast_live_poll_responses_realtimeare installed on the database you are pointed at. - Look for websocket errors in the browser console.
Response not saved
- Check
require_login. With it true, the caller needs an authenticated session, class membership, and its ownpublic_profile_id. - SQLSTATE
23505means the unique constraint fired: that profile already answered this poll. - Otherwise the insert failed
can_access_poll_response. It isSECURITY DEFINER, so aNULLresult from a missingpoll_idreads as a policy denial rather than a not-found error.
Bars stay at zero while responses arrive
- Check the choice form. Object-form choices whose
valuediffers from theirtextnever match a bucket (hooks/useCourseController.tsx:2521against:2592). - Check which element carries the choices. Only
elements[0]is counted. - Check the question type. Anything but
radiogrouporcheckboxrenders the unsupported-type message instead of a chart.