Surveys - Developer Guide
This guide covers the schema, row-level security, RPCs, triggers, and serialization behind the Survey feature. Every claim below is cited to a file and line inpawtograder/platform. For the instructor and student view of the same feature, see Surveys and Taking surveys.
Overview
A survey is one row insurveys whose json column holds a full SurveyJS definition. Instructors author it either as raw JSON or through an in-house visual builder, students answer it with the SurveyJS runtime, and staff read the results through a set of SECURITY DEFINER RPCs.
Three facts govern most of what follows:
- Survey deletion is a soft delete through
soft_delete_survey, and no restore path exists. See Soft deletion. due_dateis not enforced anywhere. See Due dates are advisory.- The visual builder understands five question types and rewrites everything else as
text. See Builder round-trip loses question types. This one destroys data, and the default template in the create form trips it.
Architecture
Tech stack
Key dependencies
survey-creator-core and survey-creator-react are installed but no survey code imports them. The visual builder is hand-written: components/survey/SurveyBuilder.tsx renders Chakra controls over its own data model (SurveyBuilderDataTypes.ts) and serializes to SurveyJS JSON through serde.ts. That is why its type coverage is narrower than SurveyJS’s, and why the round-trip loses information.Database schema
The base tables come fromsupabase/migrations/20251213200333_surveys_polls.sql. Five surveys columns were added later; each is cited individually below.
surveys
Current logical schema, not a single statement you will find in one migration:
20251213200333_surveys_polls.sql:342-360.
Constraints that will reject an otherwise plausible insert:
chk_survey_type_assigned_to_all_consistency(20251213200333:469) requirestype = 'assign_all'withassigned_to_all = true, ortype IN ('specific','peer')withassigned_to_all = false. No other combination is storable.assignment_idis not a plain FK toassignments(id). It is half of a composite FK,(assignment_id, class_id) REFERENCES assignments(id, class_id) ON DELETE SET NULL(20260222000000:18-19), backed by a unique constraint added toassignmentsin the same migration (:15-16). A survey cannot point at an assignment in another class.created_byreferencesprofiles(id)(20251213200333:477) and is written with the author’s private profile id (app/course/[course_id]/manage/surveys/new/page.tsx:333). Polls use the public one. Do not carry an id across.
survey_series
20260222000002_survey_analytics_enhanced.sql:74-82. There is no updated_at column, created_by has no ON DELETE action, and series names are unique per class.
survey_responses
20251213200333_surveys_polls.sql:311-321, with UNIQUE (survey_id, profile_id) at :401 and :459. survey_id here is the physical surveys.id, not the logical one, and it cascades on delete (:455).
profile_id is the respondent’s private profile id (app/course/[course_id]/surveys/[survey_id]/page.tsx:158).
survey_assignments
20251213200333_surveys_polls.sql:300-306, with UNIQUE (survey_id, profile_id) at :397 and :449.
survey_templates
20251213200333_surveys_polls.sql:326-337. A global template still carries a class_id; scope only widens who can read it (see survey_templates policies).
Enum types
20251213200333_surveys_polls.sql:1, :3, :5.
Triggers
The first six are declared at
20251213200333_surveys_polls.sql:1335-1345; the three broadcast triggers at :1405-1408, :1473-1476, and :1541-1544.
The two
sync_survey_type triggers make type and assigned_to_all a single logical field with two spellings. On insert, assigned_to_all = true forces type = 'assign_all', and assigned_to_all is then recomputed from type unconditionally (:794-811). On update, whichever column you changed wins and the other is derived (:813-831). Writing both to a contradictory pair does not raise. These are BEFORE triggers, so they rewrite the row before chk_survey_type_assigned_to_all_consistency is evaluated, and the pair that reaches the constraint is always consistent.RPC functions
SECURITY DEFINER functions carry the authorization checks that client-side code cannot.
soft_delete_survey(p_survey_id uuid, p_survey_logical_id uuid)
20251213200333_surveys_polls.sql:751-792. It requires both ids to identify the same row (:761-766), raises Survey not found otherwise, and raises Permission denied: instructor access required unless authorizeforclassinstructor passes (:773-775). It then stamps deleted_at on every response belonging to any row sharing the logical id, and on every such surveys row (:778-790). Called from SurveysTable.tsx:271.
create_survey_assignments(p_survey_id uuid, p_profile_ids uuid[])
20251213200333_surveys_polls.sql:637-668. Instructor-only (:655-657). It is a replace, not an append: it deletes every existing assignment for the survey before inserting (:659-660), then fills class_id from the survey. tests/e2e/surveys.test.tsx:250 asserts a student cannot call it.
get_survey_status_for_assignment(p_assignment_id bigint, p_profile_id uuid)
20260222000000_survey_assignment_grading.sql:73, redefined at 20260222000001_fix_survey_assignment_grading.sql:5. Returns one row per survey linked to the assignment with that student’s completion state. Called from components/ui/survey-status-banner.tsx:29.
get_survey_responses_with_full_context(p_survey_id uuid, p_class_id bigint)
20260222000002_survey_analytics_enhanced.sql:9. This is the RPC the analytics UI actually uses, from hooks/useSurveyAnalytics.ts:341 and :607. It returns responses joined with group membership, mentor, and section context.
Due dates are advisory
can_respond_to_survey never reads due_date (20260817120000_tighten_survey_and_poll_rls.sql:27-32). Nothing else enforces it either — not RLS, not the client, which only renders it (app/course/[course_id]/surveys/page.tsx). Surveys accept late responses, and submitted_at is what records how late.
The one place due_date is checked is publish time in the client, which refuses to move a survey to published with a past due date and saves it as a draft instead (app/course/[course_id]/manage/surveys/[survey_id]/edit/page.tsx:310-325). That is a form validation, not a control: it does not stop a response.
Soft deletion
Deletion isdeleted_at, never a DELETE. There is no DELETE policy on surveys at all, so a hard delete through PostgREST is impossible, and there is no restore UI anywhere in app/. Recovering a survey means clearing deleted_at by hand:
Row-level security
surveys policies
surveys_select_students was replaced in 20260222000000_survey_assignment_grading.sql:46-67 to add the available_at IS NULL OR available_at <= now() leg. There is no DELETE policy.
survey_responses policies
20260817120000_tighten_survey_and_poll_rls.sql replaced both write policies. The originals tested only that profile_id was one of the caller’s own profiles and never joined surveys, so a student could write to a draft, closed, soft-deleted, or unassigned survey directly through PostgREST (20260817120000:8-15).
Staff have no UPDATE and no DELETE policy on
survey_responses (20260817120000:36-37).
20260817120000:74-102. It mirrors surveys_select_students with one deliberate difference: status is tightened from “published or closed” to published only, because a closed survey stays readable but must stop accepting writes (:20-21).
survey_allows_response_editing(p_survey_id uuid) (:107-118) is the server-side counterpart to the isReadOnly guard on the student page.
The student write path is an upsert, so both policies have to pass: the autosave inserts the draft row and submit updates it in place (
20260817120000:34-37). If you tighten one, tighten both, or submission breaks with an opaque PostgREST error.survey_assignments policies
Instructors get SELECT, INSERT, UPDATE, and DELETE, all on authorizeforclassinstructor(class_id) (20251213200333:1151-1172, :1201-1207). Graders get SELECT on authorizeforclassgrader(class_id) (:1193-1198). A student gets SELECT on their own rows through survey_assignments_select_assignee (:1175-1182).
survey_assignments_select_class_member, which granted SELECT on the whole table to any class member, was dropped by 20260817120000:149. Its header explains why: responses were protected but the roster of who was assigned what was not, including survey_id values for drafts that surveys_select_students deliberately withholds (:39-49).
survey_templates policies
Delete is the odd one: it is scoped to the author, not to the class. An instructor cannot delete a colleague’s template.
survey_series policies
survey_series_select_graders (SELECT for instructors and graders in the class) and survey_series_manage_instructors (FOR ALL, instructors only), both inlining user_privileges rather than calling the helpers, at 20260222000002:101-129.
Writing a new policy
Reach for the project’s authorization helpers rather than a hand-rolleduser_privileges join. Existing definitions go both ways — live_polls_select_staff calls authorizeforclassgrader (20260817120000:162-167), while can_respond_to_survey above and the survey_series policies inline the join — so follow the helpers in anything new:
authorizeforclass(class__id bigint), authorizeforclassgrader(class__id bigint), authorizeforclassinstructor(class__id bigint), authorizeforanyclassstaff(), and authorizeforprofile(profile_id uuid) (20250916200614_tweak-gradebook-recalc-frequency.sql:262, :278, :292; 20250813234857_office-hours-and-notifications.sql:1331).
SurveyJS integration
The renderer
components/Survey.tsx is the only place a survey-core Model is constructed for a survey. Do not build one inline: the wrapper carries four non-obvious behaviors, three of them accessibility fixes for issue #881.
- The model is keyed on the serialized definition, not on object identity. The definition arrives as a JSONB column on a
TableControllerrow, so its identity changes on any realtime update or refetch even when the bytes are the same. Keying on identity rebuilt the whole question tree and threw away focus and the screen-reader cursor (:33-41). textUpdateMode = "onTyping"matters for correctness, not just for feel. Under the SurveyJS default ("onBlur") typed text lives only in the DOM input until a blur, so it is outsidesurvey.data, never autosaved, and overwritten by the next model-to-DOM sync (:53-61).onGetTitleTagNameis the supported per-survey override for title tags. A plainquestionTitleTagNameassignment is not a SurveyJS API and does nothing (:62-64).
:73-79).
Survey JSON structure
app/course/[course_id]/manage/surveys/new/form.tsx:69-84). Responses are keyed by question name.
Builder round-trip loses question types
The builder’s data model has exactly five element types:
Import goes through
importElement in serde.ts, whose default: branch coerces any unrecognized type into a text element (serde.ts:275-298):
type is in the excluded-keys list, so it is not even preserved in config. Export then writes type: "text" from the text case (serde.ts:43-52).
The builder is opened from a modal on the create/edit form (new/form.tsx:996-1002). The conversion runs on mount, not on edit: SurveyBuilder’s serialize effect fires immediately and writes the re-serialized JSON into the modal’s draft buffer (SurveyBuilder.tsx:116-121, SurveyBuilderModal.tsx:87), so clicking Use This Survey without touching a single control already writes the lossy form back to the form field (SurveyBuilderModal.tsx:24-27). Cancel discards the draft and is safe.
Analytics question coverage
The analytics panel filters to five SurveyJS types, and they are not the builder’s five:comment and boolean questions never reach analytics, and rating and nouislider are two of the types the builder destroys. A per-question analytics_config.questions[name].includeInAnalytics flag narrows the set further when present (:85-89).
Response handling
There is nosubmit.ts. The whole student write path is inline in app/course/[course_id]/surveys/[survey_id]/page.tsx.
submitted_at explicitly even though set_survey_submitted_at_trigger would also set it.
Autosave
Autosave is gated onallow_response_editing and returns early when the flag is off:
can_respond_to_survey would accept the draft insert either way.
Submit and autosave race by construction, since SurveyJS fires a final onValueChanged on blur. handleSurveyComplete cancels any pending autosave timer, awaits any in-flight one, and only then writes, so the submit is guaranteed to be the last write (:196-207). saveResponseToDb’s own hasSubmittedRef check is the second line of defense (:147). Preserve both if you touch this.
Read-only state is existingResponse?.is_submitted && !survey.allow_response_editing, ORed with the staff “view as student” mode (:313-314).
CSV export
The live export is inline inSurveyResponsesView.tsx, and it escapes for formula injection:
:299, :302).
React hooks
Survey hooks live inhooks/useCourseController.tsx unless noted.
File layout
TypeScript types
types/survey.ts:1-36. SurveyWithCounts has two count fields, not three; there is no submitted_count.
Testing
Two runners cover surveys. Playwright’stestDir is tests/e2e (playwright.config.ts:15); Jest’s testMatch is tests/unit/**/*.test.ts(x) (jest.config.js:12), so npm test never runs the E2E specs and npx playwright test never runs the unit ones.
tests/e2e/surveys.test.tsx is the main suite. Worth reading before you change behavior: draft and closed surveys staying invisible to students (:133, :148), assignment-scoped visibility (:163, :195), students being rejected by create_survey_assignments (:250), in-progress answers surviving a re-render (:589), the WCAG 1.3.2 reading order this page’s renderer notes exist for (:646), and multi-page multi-question builder coverage (:865). tests/fixtures/teamCollaborationSurvey.ts holds a reusable survey definition.
The a11y spec under tests/e2e/a11y-tasks/ is generated. Regenerate with npm run a11y:generate-specs rather than editing it.
Common development tasks
Add a question type to the visual builder
Five files, in this order:- Add the type to
ElementTypeand a matching element interface to theBuilderElementunion (components/survey/SurveyBuilderDataTypes.ts:17-19). - Add a
makeElementoverload and its defaults (components/survey/factories.ts:55-60). - Add an
exportElementcase, or export falls through to thedefault:branch (components/survey/serde.ts:41-105). - Add an
importElementcase, or import silently becomes atextelement (components/survey/serde.ts:171-299). - Add the “add question” button and its editing controls (
components/survey/SurveyBuilder.tsx:663-696).
SurveyAnalytics.tsx:82 if it should be charted.
Change the schema
npm run client-local also copies the generated types to supabase/functions/_shared/ (package.json:28). See Local setup for the fresh-volume caveat on supabase start.
Troubleshooting
Survey not visible to students
statusmust bepublished.surveys_select_studentsalso admitsclosed, but a closed survey cannot be answered.deleted_atmust beNULL.available_atmust beNULLor in the past (20260222000000:57).- If
assigned_to_allis false, the student needs asurvey_assignmentsrow. Remembercreate_survey_assignmentsreplaces the whole set. - There is no student realtime channel for surveys. A student with the list already open needs a reload.
Response not saving
- Walk the legs of
can_respond_to_surveyagainst thesurveysrow: not deleted,status = 'published',available_atpassed, and eitherassigned_to_allor a matchingsurvey_assignmentsrow. The function readsauth.uid(), so calling it from psql as a superuser returnsfalseand tells you nothing. profile_idmust be the student’s private profile id.- If the response is already submitted,
survey_responses_update_ownerrequiresallow_response_editingon the survey. - Do not look for a due-date rejection.
due_dategates nothing on this path.
Visual builder is not round-tripping
- Check the question types first. Anything outside the builder’s five comes back as
text, and that is the designed behavior ofserde.ts’sdefault:branch, not a bug in your wiring. SurveyBuilderonly accepts a parentvalueupdate that differs from what it last emitted (SurveyBuilder.tsx:103-114). A prop change identical to its own last output is ignored on purpose.- Check the browser console for a
JSON.parsefailure insafeFromJSON(SurveyBuilder.tsx:61-72).