feat(feedback): add course surveys with unlinkable anonymous responses and small-cohort suppression (fixes #286) - #291
Open
MOHITKOURAV01 wants to merge 1 commit into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: ObjectIdand hides it in the UI. That is not anonymity — it is anonymity until somebody runs a query.Instead, an anonymous response stores only:
req.user._idis never written to the document.This gets both properties at once:
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:
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-submissionsworks 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
minResponsesToReleaseresponses 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
anonymousflag: people answered under the terms they were shown.The UI says all of this out loud
The
/feedbackheader 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 endpointbackend/routes/feedbackRoutes.js—/api/feedbackbackend/server.js— route registration (2 lines)frontend/src/pages/CourseFeedback.jsx— survey list and the form, all six question typesfrontend/src/components/feedback/SurveyAuthorPanel.jsx— authoring, seal state, results viewfrontend/src/App.jsx—/feedbackroute (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 pristinemainnpx vite buildsucceeds;CourseFeedbackcode-splits into its own chunknode --checkclean on every backend fileNotes for review
ANON_SECRETfalls back toJWT_SECRETwhen 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
mainand against every other open PR, in both orders. Does not touchTeacherDashboard.jsx.