Skip to content

feat(feedback): add course surveys with unlinkable anonymous responses and small-cohort suppression (fixes #286) - #291

Open
MOHITKOURAV01 wants to merge 1 commit into
Sitaram8472:mainfrom
MOHITKOURAV01:feat/issue-286-course-feedback
Open

feat(feedback): add course surveys with unlinkable anonymous responses and small-cohort suppression (fixes #286)#291
MOHITKOURAV01 wants to merge 1 commit into
Sitaram8472:mainfrom
MOHITKOURAV01:feat/issue-286-course-feedback

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Related Issue

Closes #286

Description

Replaces the annual paper feedback slips. Those fail for one reason that dominates everything else: students do not believe they are anonymous, and they are right not to. A handwritten slip handed to the teacher being evaluated in a class of thirty is identifiable, everyone knows it, so the feedback comes back uniformly positive and the school learns nothing from an exercise it runs every year.

So anonymity here is a property of the data model, not a sentence in the instructions.

Responses are unlinkable by construction

The obvious implementation stores respondent: ObjectId and hides it in the UI. That is not anonymity — it is anonymity until somebody runs a query.

Instead, an anonymous response stores only:

respondentKey = HMAC-SHA256(`${surveyId}:${userId}`, ANON_SECRET)

req.user._id is never written to the document.

This gets both properties at once:

  • the same person submitting twice produces the same key, so duplicates are detectable — which is the requirement that normally forces these systems to keep the identity in the first place;
  • the key is scoped to one survey, so keys cannot be joined across surveys to rebuild somebody's history.

Reversing it needs the secret and a brute-force over the user table. Reading a stored id needs a SELECT. That gap is the whole feature.

The duplicate check is the filter of the update, not a read above it:

{ _id, status: 'open', responses: { $not: { $elemMatch: { respondentKey: key } } } }

Because the key is deterministic, the filter can express "this person has not answered" without the document ever having to say who this person is. Two taps on a slow connection cannot both push.

GET /my-submissions works the same way — it recomputes the caller's own key, so it confirms that they responded without revealing what they said. The server genuinely cannot reconstruct the second.

Results are sealed for small cohorts

Anonymity fails at small n whatever is stored. In a class of three, "one respondent rated this 1/5" is attributable by anybody who knows the class.

So results are withheld until minResponsesToRelease responses are in — and this binds the survey's own author. A teacher reading their own two responses is exactly the disclosure the threshold exists to prevent, and "but it's their survey" is how this control gets quietly removed in practice. Below the threshold the endpoint returns a count and a plain explanation, never the responses.

The threshold can be raised but never lowered once responses exist. Lowering it after the fact would release answers that were given on the understanding they would be pooled.

Two smaller things that matter more than they look

Text answers come back shuffled. Storage order is submission order, and submission order plus a rough sense of when somebody filled the form in is enough to attribute a comment. Anonymity that leaks through sequence position is not anonymity.

Questions freeze once anybody has answered. Editing question 3 after forty people have answered it does not update forty answers — it silently reinterprets them, and every chart built afterwards is wrong in a way nobody can see. Same for the anonymous flag: people answered under the terms they were shown.

The UI says all of this out loud

The /feedback header states plainly what is and is not recorded — a one-way code rather than a name, different per survey, results sealed below a threshold. The paper version failed on trust, so the replacement has to earn it explicitly rather than assume it.

Pages / Components Added or Modified

  • backend/models/FeedbackSurvey.js — questions, responses, respondentKeyFor(), validateAnswers(), aggregate()
  • backend/controllers/feedbackController.js — submission, the threshold-gated results endpoint
  • backend/routes/feedbackRoutes.js/api/feedback
  • backend/server.js — route registration (2 lines)
  • frontend/src/pages/CourseFeedback.jsx — survey list and the form, all six question types
  • frontend/src/components/feedback/SurveyAuthorPanel.jsx — authoring, seal state, results view
  • frontend/src/App.jsx/feedback route (9 lines)

Screenshots

Not attached. The results view is the interesting screen and it needs five real responses before it shows anything — which is the point of the feature, but does make for a poor screenshot. I can seed a demo survey if you would like to see the released and sealed states side by side.

Checklist

  • npx eslint . passes — 18 problems, matching pristine main
  • npx vite build succeeds; CourseFeedback code-splits into its own chunk
  • node --check clean on every backend file
  • Responsive layout verified
  • Tested against a live database
  • Screenshots attached

Notes for review

ANON_SECRET falls back to JWT_SECRET when unset so this works out of the box, but it is worth setting separately and I would suggest documenting it. Two reasons: rotating it is what invalidates historical keys, and it means an incident affecting the auth secret does not also hand somebody the ability to de-anonymise past feedback.

The default release threshold is 5. That is a judgement call rather than a derived number — if the school has classes small enough that 5 is still identifying, it is per-survey configurable upward.

One thing I decided and would happily revisit: responses record the respondent's role but not their class. In a school with one section per year, class plus role identifies a small enough group to make the anonymity claim false — the same disclosure the threshold is there to stop. It does mean results cannot be split by class, which is a real loss; I judged it the right trade but it is a trade.

Merges cleanly against main and against every other open PR, in both orders. Does not touch TeacherDashboard.jsx.

…ed small cohorts

Replaces the annual paper feedback slips, which fail for one reason:
students do not believe they are anonymous, and they are right not to.
A handwritten slip handed to the teacher being evaluated in a class of
thirty is identifiable, everyone knows it, and the result is uniformly
positive feedback that teaches the school nothing.

So anonymity is a property of the data model rather than a promise in
the instructions. An anonymous response stores
HMAC(surveyId + userId, ANON_SECRET) and nothing else identifying —
req.user._id is never written to the document.

That gets both properties at once. The same person submitting twice
produces the same key, so duplicates are detectable, which is the
requirement that usually forces these systems to keep the identity. And
the key is scoped to one survey, so keys cannot be joined across
surveys to rebuild somebody's history. Reversing it needs the secret
and a brute-force over the user table; reading a stored id needs a
SELECT. That gap is the whole feature.

The duplicate check is a $not/$elemMatch clause in the update filter
rather than a read above the write — because the key is deterministic,
the filter can express "this person has not answered" without the
document ever saying who this person is.

Results stay sealed until minResponsesToRelease responses are in, and
that binds the survey's author too. In a class of three, "one
respondent rated this 1/5" is attributable by anyone who knows the
class, and the author is the person most likely to know it. Exempting
them because it is their survey is how this control gets quietly
removed, so it is not exempted. The threshold can be raised but never
lowered once responses exist.

Text answers come back shuffled: storage order is submission order, and
that plus a rough sense of when somebody filled the form in is enough
to attribute a comment. Questions freeze once anybody has answered —
editing question 3 afterwards does not update forty answers, it
silently reinterprets them.

Closes Sitaram8472#286
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Course & Teaching Feedback surveys with unlinkable anonymous responses and small-cohort suppression

1 participant