Skip to main content

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 in pawtograder/platform. For the instructor and student view of the same feature, see Creating polls and Answering polls.

Overview

A poll is one row in live_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

From package.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 in supabase/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:
  • question holds the SurveyJS element list. See Question JSON.
  • is_live is the only flag any read path consults. It gates the anonymous SELECT policy (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_at is written on every open and cleared on every close, and then never read. See Expiry is not wired up.
  • require_login decides whether responses must carry a profile id. It is enforced only inside can_access_poll_response.
  • created_by has no default and no trigger. The client must supply its own public_profile_id (app/course/[course_id]/manage/polls/new/page.tsx:196), and the insert policy checks it with authorizeforprofile(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:
That is the form’s starting template verbatim (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:
A 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).
Counting reads only elements[0]. extractChoicesFromPollQuestion takes the first element’s choices (hooks/useCourseController.tsx:2511-2517) and extractPollAnswer takes the first response key beginning with poll_question_ (:2534). A poll with two questions charts only the first, and there is no other reader for the rest.
Object-form choices break the counts. extractChoicesFromPollQuestion labels a choice from choice.text || choice.label || choice.value (hooks/useCourseController.tsx:2521), but SurveyJS submits the choice’s value. Given {"value": "dp", "text": "Dynamic Programming"}, the counter keys its bucket on "Dynamic Programming" and then tries to increment "dp", which hasOwnProperty rejects (:2592, :2627). The bar stays at zero however many students answer. Use plain string choices, or make value and text identical.

Triggers

All five live in 20251213200333_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.
Nothing auto-populates 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.
deactivate_expired_polls() has no callers: no pg_cron schedule, no edge function, no client call. No read path filters on deactivates_at either. Polls never close on their own. A poll stays live until a staff member closes it, and the one-hour value is dead metadata.

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 a require_login = false poll, a caller who knows a poll’s id can insert against a poll that was never live at all.
  • require_login = false returns true unconditionally. Not “true when profile_id IS NULL”. Any caller may insert with any public_profile_id, including another student’s, and the row will look attributed.
  • require_login = true is 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.
For a poll whose count has to be defensible, set require_login = true. With it off, the combination of the unconditional true branch and NULL-tolerant uniqueness means the response set carries no integrity guarantee at all.

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 is SECURITY 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 in hooks/useCourseController.tsx and read through the course controller’s livePolls TableController.
useLivePolls() returns a bare array, not { data, isLoading }, and usePollResponseCounts() returns { counts, isLoading }, not a bare record. Destructuring either the other way yields undefined with no type error at the call site if you have widened the type.
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 no components/polls/ directory.
There is no edit route. 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

Only PollResponseData is used, by app/poll/[course_id]/page.tsx:13. PollQuestion and MultipleChoicePollQuestion are imported nowhere and describe a shape no table holds: stored questions are SurveyJS elements with type: "checkbox" or "radiogroup" and a title, not type: "multiple-choice" with a prompt, and nothing anywhere persists correct_choices. Do not model new code on them. Row types come from Database["public"]["Tables"]["live_polls"]["Row"], which is what the hooks use.

Testing

Poll E2E tests are in tests/e2e/polls.test.tsx, 16 active tests in one test.describe("Polls") block (:50).
They cover the student empty state (: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

  1. Check is_live. It is the only field any student read path filters on.
  2. Confirm the student is enrolled in class_id. live_polls_select_live does not check enrollment, but the student page and hook are both scoped to a course.
  3. If more than one poll is live, remember that /poll/<course_id> serves only the newest by created_at (app/poll/[course_id]/page.tsx:43-45). Close the others.
  4. Do not investigate deactivates_at. Nothing reads it.

Real-time updates not arriving

  1. Confirm the client joined the right topic. Students are on class:{class_id}:students, not a per-user topic.
  2. Check whether the poll is live. A student receiving a data-free DELETE where you expected an UPDATE is the intended behavior for a poll that is not live (20260817120000:235-247).
  3. Check that broadcast_live_polls_realtime and broadcast_live_poll_responses_realtime are installed on the database you are pointed at.
  4. Look for websocket errors in the browser console.

Response not saved

  1. Check require_login. With it true, the caller needs an authenticated session, class membership, and its own public_profile_id.
  2. SQLSTATE 23505 means the unique constraint fired: that profile already answered this poll.
  3. Otherwise the insert failed can_access_poll_response. It is SECURITY DEFINER, so a NULL result from a missing poll_id reads as a policy denial rather than a not-found error.

Bars stay at zero while responses arrive

  1. Check the choice form. Object-form choices whose value differs from their text never match a bucket (hooks/useCourseController.tsx:2521 against :2592).
  2. Check which element carries the choices. Only elements[0] is counted.
  3. Check the question type. Anything but radiogroup or checkbox renders the unsupported-type message instead of a chart.