Skip to main content

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

Overview

A survey is one row in surveys 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_date is 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 from supabase/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:
Original 17 columns at 20251213200333_surveys_polls.sql:342-360. Constraints that will reject an otherwise plausible insert:
  • chk_survey_type_assigned_to_all_consistency (20251213200333:469) requires type = 'assign_all' with assigned_to_all = true, or type IN ('specific','peer') with assigned_to_all = false. No other combination is storable.
  • assignment_id is not a plain FK to assignments(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 to assignments in the same migration (:15-16). A survey cannot point at an assignment in another class.
  • created_by references profiles(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_id and version are versioning scaffolding that nothing exercises. version is hard-coded to 1 on insert (app/course/[course_id]/manage/surveys/new/page.tsx:331) and no code path ever increments it. Editing a survey updates the existing row in place (app/course/[course_id]/manage/surveys/[survey_id]/edit/page.tsx:375), so there is exactly one row per logical survey_id and the two ids are always in one-to-one correspondence. Anything you write that joins on survey_id expecting several versions will find one. soft_delete_survey and the responses page both loop over “all versions” (20251213200333:778-790, responses/page.tsx:54); those loops are correct but degenerate.

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.
class_id is NOT NULL with no default. A direct insert that omits it fails. Use create_survey_assignments, which fills it from the survey’s class (20251213200333:663-664).

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.
All three broadcasts go to the staff channel only. Students receive no realtime survey traffic and need a reload to see a survey publish.

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.
Two sibling RPCs exist and have no callers: get_survey_responses_with_group_context(p_survey_id uuid, p_class_id bigint) (20260222000000:112) and get_survey_series_trend_data(p_series_id uuid, p_class_id bigint) (20260222000002:135). They are typed in SupabaseTypes.d.ts and are callable, but nothing reads them. Note the parameter order on the group-context one: survey first, class second. Calling it positionally with the class id first fails on type mismatch.

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 is deleted_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-rolled user_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:
The helpers are 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 TableController row, 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 outside survey.data, never autosaved, and overwritten by the next model-to-DOM sync (:53-61).
  • onGetTitleTagName is the supported per-survey override for title tags. A plain questionTitleTagName assignment is not a SurveyJS API and does nothing (:62-64).
The theme is applied in an effect from the Chakra color mode (:73-79).

Survey JSON structure

That is the create form’s default template verbatim (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).
This is unrecoverable data loss on write, not a display limitation. Open a survey containing a rating, dropdown, matrix, ranking, or nouislider question in the visual builder and save, and every one of them comes back as a single-line text input. The original type is dropped, and only the leftover keys survive in config (rateValues and friends), spread back onto a text element where they do nothing.The create form’s own default template contains a rating question (new/form.tsx:76), and the seeded course templates are built almost entirely from rating (scripts/DatabaseSeedingUtils.ts:308 onward). The edit page reuses the same form ([survey_id]/edit/page.tsx:9), so this reaches existing surveys with existing responses. Edit those surveys as raw JSON.
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 no submit.ts. The whole student write path is inline in app/course/[course_id]/surveys/[survey_id]/page.tsx.
Note that the client sets submitted_at explicitly even though set_survey_submitted_at_trigger would also set it.

Autosave

Autosave is gated on allow_response_editing and returns early when the flag is off:
With the flag off, nothing is persisted until the student clicks Complete. That is the behavior the user-facing pages describe, and it is enforced here rather than in RLS: 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 in SurveyResponsesView.tsx, and it escapes for formula injection:
Applied to both the header row and every cell (:299, :302).
A second, unused exporter sits next to it. responses/export.ts:56-114 builds the same CSV but escapes only commas, quotes, and newlines: it has no formula-injection guard (:99-108). Nothing imports it. Do not resurrect it without porting escapeCSVValue.

React hooks

Survey hooks live in hooks/useCourseController.tsx unless noted.
useSurveyResponses builds a TableController filtered to is_submitted = true and deleted_at IS NULL (hooks/useCourseController.tsx:2727-2737). In-progress drafts are invisible to it, so a response count taken from this hook is a submitted count. It also returns a SurveyResponseWithProfile declared locally at :2706-2708 whose profiles shape differs from the same-named export in types/survey.ts:11: no sis_user_id. Import the right one.

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’s testDir 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:
  1. Add the type to ElementType and a matching element interface to the BuilderElement union (components/survey/SurveyBuilderDataTypes.ts:17-19).
  2. Add a makeElement overload and its defaults (components/survey/factories.ts:55-60).
  3. Add an exportElement case, or export falls through to the default: branch (components/survey/serde.ts:41-105).
  4. Add an importElement case, or import silently becomes a text element (components/survey/serde.ts:171-299).
  5. Add the “add question” button and its editing controls (components/survey/SurveyBuilder.tsx:663-696).
Rendering needs nothing: SurveyJS handles any type it knows. Add the type to 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

  1. status must be published. surveys_select_students also admits closed, but a closed survey cannot be answered.
  2. deleted_at must be NULL.
  3. available_at must be NULL or in the past (20260222000000:57).
  4. If assigned_to_all is false, the student needs a survey_assignments row. Remember create_survey_assignments replaces the whole set.
  5. There is no student realtime channel for surveys. A student with the list already open needs a reload.

Response not saving

  1. Walk the legs of can_respond_to_survey against the surveys row: not deleted, status = 'published', available_at passed, and either assigned_to_all or a matching survey_assignments row. The function reads auth.uid(), so calling it from psql as a superuser returns false and tells you nothing.
  2. profile_id must be the student’s private profile id.
  3. If the response is already submitted, survey_responses_update_owner requires allow_response_editing on the survey.
  4. Do not look for a due-date rejection. due_date gates nothing on this path.

Visual builder is not round-tripping

  1. Check the question types first. Anything outside the builder’s five comes back as text, and that is the designed behavior of serde.ts’s default: branch, not a bug in your wiring.
  2. SurveyBuilder only accepts a parent value update that differs from what it last emitted (SurveyBuilder.tsx:103-114). A prop change identical to its own last output is ignored on purpose.
  3. Check the browser console for a JSON.parse failure in safeFromJSON (SurveyBuilder.tsx:61-72).