Business Logic to Port
The framework comes from TourCockpit; the business logic below is the part written fresh, and the part where a rewrite can silently change a number a nonprofit has already reported to a funder. This page states that logic as numbered, testable rules, precisely enough that the TypeScript services can be written and tested from it without reading the Python. Each rule names the Python source and the Python tests that cover it today.
How to read it:
- Source of truth for behaviour is the running code:
CatalyzeUp/sondely, branchdevelopat06debbf(21 September 2026), directorybackend/. Paths below are relative tobackend/unless they start withfrontend/. - Rule ids (
SC-3,RM-7) are stable: name them in the TypeScript test titles so a failing test points back here. - Where a product spec says something else, the rule states what the code does and the disagreement is listed under "Spec says otherwise". The full list is in Spec and code disagreements.
- Parity means the TypeScript result equals the Python result for the same stored data. Fix marks a place where parity would carry a defect forward; the rule says which behaviour the TypeScript version takes, and the defect is tracked in Testing and Bugs.
- Anything not verified by reading code or running a test is marked inferred.
Sibling pages: Overview, Framework Copy, Data Model, Auth, API Routes, Testing and Bugs, Frontend Screens, Translation, Cutover Runbook, Infrastructure, Agent Layer.
Cross-cutting numeric rules
The cutover requires scores recomputed in TypeScript to equal the stored Python scores (see the Overview). Two Python behaviours differ from JavaScript defaults and must be reproduced exactly.
NU-1. Rounding is Python's round(), not Math.round and not toFixed. Python rounds the exact binary value to the nearest, and on an exact tie it picks the even digit. Number.prototype.toFixed agrees except on exact binary ties, where it rounds away from zero. Implement one helper, pyRound(x, digits), and use it everywhere a rule below says "rounded". Test vectors, verified against Python 3 and Node on chipdev:
| Input | Python round |
toFixed |
pyRound must return |
|---|---|---|---|
round(0.125, 2) |
0.12 | 0.13 | 0.12 |
round(0.375, 2) |
0.38 | 0.38 | 0.38 |
round(2.675, 2) |
2.67 | 2.67 | 2.67 |
round(-0.125, 2) |
-0.12 | -0.13 | -0.12 |
round(2.5) |
2 | 3 | 2 |
round(1100 / 1400 * 100, 2) |
78.57 | 78.57 | 78.57 |
round(1600 / 2150 * 100, 2) |
74.42 | 74.42 | 74.42 |
NU-2. Number parsing for scoring is Python's float(). Rating answers arrive as numbers or strings and are parsed with float() (app/services/scoring.py:15). Implement pyFloat(value) returning a number or "invalid":
| Input | Python float() |
Number() |
pyFloat |
|---|---|---|---|
4, 4.5, "4", " 4 " |
4, 4.5, 4, 4 | same | same |
true / false |
1.0 / 0.0 | 1 / 0 | 1 / 0 |
"" |
error | 0 | invalid |
"hello", null, a list, an object |
error | NaN or 0 | invalid |
"1_0" |
10.0 | NaN | 10 |
"0x10" |
error | 16 | invalid |
"inf", "Infinity" |
inf | NaN, Infinity | Infinity |
"nan" |
nan | NaN | NaN (see SC-4) |
NU-3. Compare numbers numerically, never as JSON text. Python serialises a float total as 640.0 and an integer weight as 800; TypeScript serialises both as integers. Contract tests compare parsed numbers.
NU-4. Time. Python stores naive UTC datetimes (datetime.utcnow()); all durations below (timedelta(days=n), 24 hour cooldowns) are exact 24 hour multiples in UTC, not calendar days in any zone. The TypeScript version stores timestamptz and computes in UTC. Dates shown in messages are formatted YYYY-MM-DD from the UTC value (app/api/surveys/routes.py:566).
Question types and answers
Six question types exist (app/models/enums.py:17-24). Answers are stored as one dictionary per response, responses: {question_id: value}, and today nothing on the server checks them (SurveyResponseCreate.responses: Dict[str, Any], app/models/survey.py:290). The shapes below are what the take page sends (frontend/src/pages/SurveyTakePage.tsx).
| Type | Answer sent today | Unanswered value sent | Builder's default scoring (frontend/src/pages/SurveyBuilderPage.tsx:358-382) |
|---|---|---|---|
BOOLEAN |
true / false |
null |
binary, true_value 10, false_value 0 |
RATING |
integer 0 to 5 | null |
direct_scale, min_value 0, max_value 5, default labels N/A, Poor, Fair, Good, Very Good, Excellent |
SINGLE_CHOICE |
the English option string | "" |
weighted_options, weights n, n-1, ..., 1 in option order |
MULTIPLE_CHOICE |
array of English option strings | "" |
weighted_options (not multi_select_sum, see SC-9) |
TEXT |
string | "" |
qualitative |
COUNTRY |
legacy only: {country, region} |
null |
qualitative. The take page filters COUNTRY questions out (SurveyTakePage.tsx:567); location now comes from the respondent profile (PR-1) |
QT-1. Answers are always stored with the source-language option key, whatever language the respondent saw. A translated take page maps the displayed option back by index through original_options before submitting (SurveyTakePage.tsx:615-634). Tests: test_translation_edge_cases.py::TestScoringWithTranslations (3).
QT-2. What the TypeScript version must validate on write (Zod, per question, in respond, sessions/save and sessions/complete). None of this is enforced in Python today; each rule is a deliberate change and gets its own failing-first test:
- Every key in
responsesis the id of a question of this survey. Unknown keys are refused (today they are stored). - A
requiredquestion has a non-empty answer (null, missing and""count as empty). Today only the browser checks this (SurveyTakePage.tsx:645-651). Sessionssaveis exempt: partial answers are its purpose. BOOLEAN: a JSON boolean.RATING: a finite number within[scoring_config.min_value ?? 0, scoring_config.max_value ?? 5]. Strings,NaNand infinities are refused (see SC-4 for what Python does with them). Whether non-integers are allowed is open: Python scores 3.5 as 3.5 (test_scoring_comprehensive.py::test_float_answer) and the UI only sends integers.SINGLE_CHOICE: a string equal to one ofoptions(exact match on write; the case-insensitive match in SC-8 stays for scoring stored data).MULTIPLE_CHOICE: an array of distinct strings, each one ofoptions.TEXT: a string; a maximum length is to be set (inferred: none exists anywhere today).COUNTRY: refused on new writes; read as{country: string, region?: string}from legacy data.
QT-3. Stored answers that fail these schemas are not rewritten: the migration puts them in quarantine (see Data Model), and the scoring functions below stay tolerant so legacy rows score exactly as they did.
QT-4. Question creation rules today (app/models/survey.py:103-118): text at least 1 character, order >= 0 required, required defaults to false, weight defaults to 1.0, options defaults to []. Not checked: options present for choice types, option_weights keys matching options, group_id naming an existing group. On POST /surveys the supplied order is ignored and replaced by the array index (app/api/surveys/routes.py:81); on POST /surveys/{id}/questions it is kept, so duplicate orders are possible.
Scoring
Source: app/services/scoring.py (pure functions, no database). Called once per submission in app/api/surveys/routes.py:575-587. Tests: tests/test_scoring.py (27), tests/test_scoring_comprehensive.py (38), tests/test_structured_assessments.py::TestAggregateGroupScores (8) and TestGroupScoringAPI (5), tests/test_survey_builder_v2.py::TestGroupScoringOnResponse (3).
Per-question scorers
A question is scored when it has a truthy scoring_config. The config fields are type, min_value, max_value, true_value, false_value, option_weights (app/models/survey.py:43-50). There is no max_points, scale_max, positive_answer, mode or weight_multiplier field in the code.
SC-1. direct_scale (scoring.py:13-25): v = pyFloat(answer); if invalid, the score is 0 and no clamping happens (so "" scores 0 even when min_value is 1). Otherwise v = max(v, min_value) when min_value is not null, then v = min(v, max_value) when max_value is not null. The score is the clamped rating itself, not a fraction of anything.
| Config | Answer | Score | Test |
|---|---|---|---|
| min 0, max 5 | 3 / 3.5 / "4" |
3 / 3.5 / 4 | test_int_answer, test_float_answer, test_string_number |
| min 0, max 5 | 10 / -1 |
5 / 0 | test_clamp_above_max, test_clamp_below_min |
| min 0, max 5 | null, "hello" |
0 | test_none_answer, test_garbage_string |
| min 0, max 50 | 5 |
5 | test_old_max_50_bug |
SC-2. binary (scoring.py:28-34): true_value = float(config.true_value) defaulting to 1.0 only when the key is absent; same for false_value defaulting to 0.0. The answer is "true" when it equals (Python ==) one of True, "true", "True", "yes", "Yes", 1, "1". Because 1.0 == 1 == True in Python, the number 1.0 is true. "TRUE", "YES", "y", 0, "", "No" are false.
| Answer | Score with true 10, false 0 | Test (test_scoring_comprehensive.py::TestBooleanEdgeCases) |
|---|---|---|
true, "true", "True", "yes", "Yes", 1, "1" |
10 | 7 tests |
false, "false", "No", 0, "", null |
0 | 6 tests |
SC-3. Explicit null versus absent config keys. Questions created through the API store ScoringConfig.dict(), which writes every field, so absent values are stored as null (routes.py:91). Questions created from a template store the raw dict, where absent keys are missing (app/api/templates/routes.py:114). This changes results:
binarywithtrue_value: null:float(None)raises, the exception handler in SC-5 returns 0, so every answer scores 0 (inferred from code; no test covers it). With the key absent the default 1.0 applies.- Group aggregation reads
max_valuewith a default of 5 only when the key is absent (GR-3). The TypeScript scorer must reproduce both readings for stored data, which means the stored config must keep the distinction betweennulland missing through the migration (a JSON column, not typed columns), or the migration must normalise and the parity test must be run on the normalised data. Decide in Data Model.
SC-4. Non-finite ratings. pyFloat("nan") is NaN; max(nan, 0) and min(nan, 5) keep NaN in Python, so the score and the total become NaN and the response is stored before serialisation fails (inferred: a 500 after the insert, since Starlette refuses NaN in JSON). "inf" clamps to max_value. Fix: QT-2 rule 4 refuses these on write; the scorer, for legacy data, returns 0 for NaN (record any legacy NaN in quarantine).
SC-5. Scorer failure. Any exception inside a scorer yields 0 for that question and is logged; an unknown type yields 0 (scoring.py:101-111). Test: test_unknown_scoring_type.
SC-6. qualitative always scores 0 and is included in the breakdown and the total (as 0). Test: test_always_zero, test_with_qualitative_question.
SC-7. Missing answers. A scored question whose answer is missing or null gets 0.0 in the breakdown (scoring.py:97-99). "" is not null: it goes to the scorer (and scores 0 for every type). Test: test_missing_answer, test_partial_answers.
SC-8. weighted_options (scoring.py:37-49): key = pyStr(answer); exact lookup in option_weights, then case-insensitive lookup (first key in insertion order whose lowercase equals), else 0. option_weights: null or {} gives 0. pyStr is Python str(): True becomes "True", 4.0 becomes "4.0" (JavaScript gives "4"), a list becomes "['a', 'b']". Tests: TestMultipleChoiceEdgeCases (6), TestWeightedOptionsScoring (2).
SC-9. multi_select_sum (scoring.py:52-57): a list sums option_weights[str(item)] per item, exact match only, missing items 0, duplicates counted twice, no cap. A non-list answer is looked up as a single key, exact match only. Because the builder assigns weighted_options to MULTIPLE_CHOICE questions, a scored multi-select created in the UI receives a list and scores 0 under SC-8 (inferred from SurveyBuilderPage.tsx:368-374; multi_select_sum is reachable only through the API). Tests: TestMultiSelectSumScoring (3).
Totals
SC-10. calculate_survey_score(questions, responses) returns (total, breakdown) (scoring.py:69-119):
breakdown[question_id]exists for every question with a scoring config, answered or not.totalis the plain sum of the breakdown, in question iteration order, not rounded and not normalised. It is raw points, not a percentage.- If no question has a scoring config, both are
null. Test:test_no_scoring_config_returns_none. - Unscored questions (
scoring_confignull) are absent from the breakdown. Tests:test_full_survey_known_total,TestScoreBreakdownConsistency(3),TestRealWorldSurveyScoring(4).
SC-11. Client-supplied score. The request may carry score. It is ignored when the server computed a total, and stored as is when the survey has no scored question (routes.py:601). Tests: test_client_score_overridden, test_unscored_survey_stays_none. Fix: the TypeScript version ignores a client score always; legacy rows keep theirs.
SC-12. N/A. There is no N/A handling. Rating 0 is labelled "N/A" in the builder's default labels yet scores 0 and counts in every maximum. Qualitative questions add 0 to the total. Nothing is excluded from any denominator, because the flat total has no denominator.
Group results
Computed only when the survey has a non-empty question_groups and the breakdown is non-empty (routes.py:583-587). Source: aggregate_group_scores (scoring.py:122-190).
GR-1. Groups are processed in ascending order (ties keep input order). Each group collects the questions whose group_id equals the group id, in question iteration order. Questions with no group_id, or an unknown one, are in no group and do not count in overall_percentage. Test: test_questions_without_group_id_excluded, test_groups_sorted_by_order.
GR-2. For each question in a group: weight = question.weight (default 1.0), raw = breakdown[question_id] (default 0, so unscored questions in a group contribute 0 but still add their weight to the maximum), max_raw = scoring_config.max_value (GR-3).
weighted = raw / max_raw * weightwhenmax_rawis truthy and positive, else 0.- Question entry:
{question_id, category: question.text, score: pyRound(weighted, 4), max_possible_score: weight, percentage: pyRound(weighted / weight * 100, 2)}(0 when weight is 0).
GR-3. max_raw defaults to 5 only when the max_value key is absent (template questions, legacy data). When it is present and null (every API-created binary, weighted or qualitative question, see SC-3) the question contributes 0 to the group score while its weight still counts in the maximum. When a binary question's true_value exceeds max_raw the percentage exceeds 100. Fix candidate, not parity: listed in Testing and Bugs.
GR-4. Group entry: {group_id, group_name, color, tint, total_score: pyRound(sum of unrounded weighted, 4), max_possible_score: sum of weights, percentage: pyRound(total / max * 100, 2), question_scores}. A group with no questions has total 0, max 0, percentage 0, empty question_scores. Test: test_empty_group_no_questions.
GR-5. overall_percentage = pyRound(sum of all group totals / sum of all group maxima * 100, 2), 0 when the maximum is 0. This is a weight-weighted mean, not the mean of group percentages.
GR-6. Worked vectors that must pass unchanged (TestAggregateGroupScores):
| Case | Inputs | Expected |
|---|---|---|
| Two groups | g1: q1 weight 100 answered 4; g2: q2 weight 200 answered 3; max 5 | g1 80 of 100, 80%; g2 120 of 200, 60%; overall 66.67 |
| Weights in one group | weight 800 answered 5, weight 600 answered 2.5 | 1100 of 1400, 78.57%; question scores 800 (100%), 300 (50%) |
| Missing answer | two weight 100 questions, one answered 3 | 60 of 200, 30% |
| Mixed scales | max 10 answered 7, max 100 answered 50, weights 100 | 70 and 50, 60% |
| Maslow style | vital 800 (4), 600 (3); safety 500 (5); esteem 250 (2) | vital 1000 of 1400, 71.43%; safety 100%; esteem 40%; overall 74.42 |
GR-7. Group colours are validated, not converted (app/models/survey.py:8-40): a known name (red, pink, orange, yellow, amber, green, blue, indigo, purple, gray, grey, case-insensitive, stored as given), or a 3 or 6 digit hex (3 digits expanded, case kept), else #4F46E5. Applied on group create and update and again when a stored GroupResult is read. Tests: tests/test_group_color.py (5).
Result types
SC-13. result_type is one of flat (default), pyramid, radar, breakdown (app/models/enums.py:35-40). It only selects the visualisation; scoring is identical for all four. A survey with groups and result_type: flat still gets group results; a pyramid survey without groups gets none. An invalid value is a 422 (test_invalid_result_type_rejected).
Spec says otherwise
The Scoring System spec describes a normalised percentage score (points earned / max points * 100), max_points default 10, positive_answer, auto-distributed weights, multi-select equal-weight mode capped at max_points, weight_multiplier, a per-response score.percentage, and qualitative questions excluded from the denominator. None of this exists in code. The stored score is a raw sum (SC-10); the only percentage is the group overall_percentage (GR-5), computed only for grouped surveys. The spec marks multi_select_sum "not yet"; the code implements it (SC-9). The Maslow spec says 5 levels, 20 questions, category score as an average and an overall average of levels; the code has 6 groups, 24 questions and a weight-normalised sum (TP-3). The TypeScript version ports the code.
Structured assessments and templates
Templates are static data in code, not a collection (app/data/survey_templates.py), served by app/api/templates/routes.py. Tests: tests/test_templates.py (7), tests/test_structured_assessments.py::TestMaslowTemplate (6) and TestNewTemplates (6).
TP-1. Catalogue. GET /templates (no auth) returns one summary per template: id, title, description, category, question_count, question_types, result_type (default flat), group_count. GET /templates/{id} (no auth) returns the full template plus question_count, or 404 Template not found.
TP-2. Instantiation. POST /templates/{id}/create-survey with {organization_id, title?, description?} (templates/routes.py:47-127):
- Unknown template: 404. Unknown organization: 400
Organization not found. Caller not OWNER or ADMIN of it: 403. - Creates a
DRAFTsurvey with new cuididandpublic_id,titleanddescriptionfrom the body or the template,settings {},min_days_between_attempts 0,auto_remind true,recurrence_enabled false,reminder_intervals [0, 3, 7],template_id,result_typeandquestion_groupscopied from the template (group ids kept as in the template, for examplevital). - Questions copied in template order with
order= index,requireddefault true,weightdefault 1.0,scoring_config,group_idandrating_labelscopied as raw dicts (SC-3). - Returns 201 with the survey and its questions.
TP-3. The nine templates (SURVEY_TEMPLATES, survey_templates.py:50-293): three grouped, six flat. All rating questions use a 0 to 5 scale.
| id | Result | Groups (order: id, weight per question) | Questions | Scoring |
|---|---|---|---|---|
maslow-needs-v1 |
pyramid | 0 vital (water 800, food 800, air 700, sleep 600), 1 physiological (500, 500, 500, 600), 2 safety_development (500 x4), 3 love_belonging (300 x4), 4 esteem (250 x4), 5 self_actualization (200 x4) |
24 RATING, all with rating_labels 0 to 5 |
direct_scale min 0 max 5; flat total at most 120, group maximum 10,000 (the weights: vital 2,900, physiological 2,100, safety 2,000, love 1,200, esteem 1,000, self-actualization 800) |
team-health-check-v1 |
radar | communication, collaboration, leadership, wellbeing, growth, 4 questions each, weight 1.0 |
20 RATING | direct_scale min 0 max 5 |
program-impact-baseline-v1 |
breakdown | quality_of_life, economic_empowerment, social_capital, personal_development, 4 each, weight 1.0 |
16 RATING | direct_scale min 0 max 5 |
community-health-assessment |
flat | none | 12: 6 RATING, 3 SINGLE_CHOICE, 2 BOOLEAN, 1 TEXT | rating direct_scale max 5 (no min); visit frequency and sleep hours weighted_options (sleep: less than 5 is 1, 5-6 is 3, 7-8 is 5, more than 8 is 4); booleans binary 5/0; barrier choice and text unscored |
youth-mentorship-pre-post |
flat | none | 15: 8 RATING, 3 SINGLE_CHOICE, 3 BOOLEAN, 1 TEXT | ratings max 5, two 1 to 5 frequency choices, booleans 5/0, one unscored choice, one unscored text |
food-security-baseline |
flat | none | 10: 5 RATING, 5 BOOLEAN | max 5 and 5/0 |
volunteer-satisfaction |
flat | none | 8: 6 RATING, 2 TEXT | max 5; text unscored |
program-impact-evaluation |
flat | none | 20: 11 RATING, 4 BOOLEAN, 3 SINGLE_CHOICE, 2 TEXT | ratings max 5, booleans 5/0, one 5 to 1 "affected your daily life" choice, two unscored choices, two unscored texts |
beneficiary-needs-assessment |
flat | none | 14: 8 SINGLE_CHOICE, 2 BOOLEAN, 4 TEXT | nothing scored (score is null) |
Group colours and tints are part of the data (Maslow: red 700, pink 700, orange 600, yellow 500, green 700, blue 600). The weights must match test_maslow_template_weights_match_original, test_team_health_template_weights, test_program_impact_template_weights exactly; port the data file verbatim (texts, labels, weights) as a TypeScript constant with a snapshot test.
TP-4. A survey created from a template is an ordinary survey: everything in the lifecycle section applies, and template_id is informational only.
Spec says otherwise. Structured Assessments specifies POST /surveys/from-template, a survey_templates collection with is_system, and marks Team Health and Program Impact "not yet". The code uses POST /templates/{id}/create-survey, static data, and ships both. The spec's "Admin test mode" scores client-side only; that remains true (GET /surveys/{id}/preview returns the survey to OWNER or ADMIN in any status, nothing is stored).
Survey lifecycle
Source: app/api/surveys/routes.py, app/models/survey.py:160-247. Tests: tests/test_surveys.py (50), tests/test_survey_builder_v2.py (33).
LC-1. Statuses are DRAFT, PUBLISHED, CLOSED, plus an independent is_active flag (default true). A survey is "live" when status = PUBLISHED and is_active = true.
| Operation | DRAFT | PUBLISHED | CLOSED | Rule source |
|---|---|---|---|---|
Public read GET /surveys/public/{public_id} |
404 | yes, if active | 404 | routes.py:217-227 |
Submit POST /surveys/{id or public_id}/respond |
404 | yes, if active | 404 | routes.py:509-522 |
| Admin preview | yes | yes | yes | routes.py:300-333 |
| Start a session | yes (no status check) | yes | yes (no status check) | sessions/routes.py:50-113, inferred risk |
| Assign members, translate, export | yes | yes | yes | no status checks |
| Reminders sent | yes (no status check) | yes | yes | reminder_engine.py, see RM-9 |
| Add or delete a question | only with zero responses | same | same | LC-4 |
LC-2. Transitions are unrestricted: PUT /surveys/{id} with any status value moves between any two states, including CLOSED to PUBLISHED and PUBLISHED to DRAFT (test_survey_status_transitions asserts the reopen succeeds). There is no publish endpoint and no check that a survey has questions before publishing (the builder checks in the browser).
LC-3. Update semantics. PUT /surveys/{id} (OWNER or ADMIN) sets every field present and not null (routes.py:348), so a field cannot be cleared to null through the API. Updatable: title (1 to 255), description, settings, status, is_active, min_days_between_attempts, auto_remind, require_email, recurrence_enabled, recurrence_interval_days, reminder_intervals, result_type, question_groups (replaced whole), template_id. None of these is blocked by existing responses, so groups, group membership and result type can change after responses exist, and stored group_results then disagree with a recomputation.
LC-4. Immutability. POST /surveys/{id}/questions and DELETE /surveys/{id}/questions/{qid} return 409 Cannot modify questions: survey has {n} responses. Delete all responses first. when any response exists, in any status (routes.py:420-425, 472-477). There is no question update endpoint (QuestionUpdate is defined and unused). The builder edits an existing survey by PUT on the survey, then deleting every question and re-creating them all with new ids (SurveyBuilderPage.tsx:435-470); with responses present the PUT succeeds and the question calls fail with 409, which the builder does not surface (inferred from the frontend code). Every re-save also drops question translations, since they live on the deleted questions. Tests: TestSurveyImmutability (5).
LC-5. Unlocking. DELETE /surveys/{id}/responses (OWNER only, or the creator) deletes all responses of the survey and returns {message, deleted_count}. Sessions and assignments are left as they are.
LC-6. Delete survey (OWNER or ADMIN): deletes the survey, its questions and its responses. Sessions, assignments and notification mutes referring to it are left orphaned; the reminder engine skips assignments whose survey is gone.
LC-7. Create (routes.py:29-113): organization must exist (400) and caller must be OWNER or ADMIN of it (403); new survey is DRAFT, active, with defaults min_days_between_attempts 0, auto_remind true, require_email true, result_type flat, recurrence_enabled false, reminder_intervals [0, 3, 7], available_languages ["en"], default_language "en".
LC-8. Access (app/utils/permissions.py:39-80): the survey's creator always has full access, treated as OWNER when no membership row exists, even after being removed from the organization or demoted. Others need a membership; admin routes need OWNER or ADMIN. Listing (GET /surveys) returns surveys the user created plus all surveys of organizations where the user is OWNER or ADMIN, newest first. Fix: the TypeScript version authorises through membership only (see Auth).
Spec says otherwise. Impact Comparison says "any PUT to a survey's questions returns an error": there is no question PUT, and survey PUT with group changes is not blocked.
Submitting a response
Source: POST /surveys/{survey_id}/respond, app/api/surveys/routes.py:498-649. Optional authentication: a bearer token attaches user_id.
RS-1. The survey is found by id or public_id, with status PUBLISHED and is_active true; else 404 Survey not found or not accepting responses.
RS-2. If require_email (default true) and no respondent_email: 422 Email is required to take this survey. The email is stored as sent: not lower-cased, not validated as an address (inferred risk: every email-keyed rule below is case-sensitive).
RS-3. Profile requirements from the organization's profile_config (PR-1): geo required and geo.country falsy gives 422 Location is required to take this survey; name required and respondent_name falsy gives 422 Name is required to take this survey. The email entry of profile_config is not enforced here; require_email on the survey is.
RS-4. Cooldown at submit (SE-6): only when respondent_email is present and min_days_between_attempts > 0. The latest response of the same survey with the same exact email: if now < submitted_at + min_days, 409 You can retake this survey after YYYY-MM-DD. A request without email, or a signed-in user who omits it, is never gated here.
RS-5. Scores are computed by SC-10 and GR over the survey's questions (in stored order) and stored with the response.
RS-6. Stored response: id (cuid), survey_id (the internal id even when called with the public id), respondent_email, respondent_name, geo, context (PR-3), user_id (caller or null), responses (unchecked, see QT-2), score (SC-10 or SC-11), score_breakdown, group_results, overall_percentage, completed_at = submitted_at = now, next_eligible_date = now + min_days only when min_days > 0 else null, recurrence_reminders_sent 0, last_recurrence_reminder_at null.
RS-7. Assignment completion (only with a signed-in caller, routes.py:614-638): the caller's assignment for this survey, if any, becomes COMPLETED with last_completed_at = now; when the survey has recurrence_enabled and recurrence_interval_days, it is instead reset for the next cycle: status PENDING, current_due_date now, next_due_date now + interval, reminders_sent 0, last_reminder_at null. A failure here is logged and does not fail the submission. In TypeScript this runs in the same transaction as the insert.
RS-8. Returns 201 with the stored response.
RS-9. Double record with sessions. When the take page has a session it calls POST /sessions/{id}/complete and then respond (SurveyTakePage.tsx:655-672). The session completion inserts its own survey_responses document with _id but no id, no score and no email (sessions/routes.py:211-222), so one attempt creates two response documents. Inferred consequences: impact counts that respondent as having two attempts; the respondent dashboard doubles attempts; reading that document through the SurveyResponse model fails because id is required. Fix: in TypeScript, completing a session and submitting are one operation producing one response (see SE-4); the migration drops or quarantines the id-less session copies (see Data Model).
Tests: test_surveys.py::TestSurveyResponses (4), test_survey_builder_v2.py::TestRequireEmailEnforcement (3) and TestCooldownEnforcement (3), test_attempt_gate.py (4), test_respondent_profile.py (5), test_response_context.py (3 of 7).
Sessions and attempts
Source: app/api/sessions/routes.py, app/models/survey_session.py. Every route needs a signed-in user and acts on that user's own sessions only. Tests: tests/unit/test_session_model.py targets an unused model (app/models/session.py) and is dropped; session behaviour is covered by test_drip_campaigns.py::TestIncompleteDrip* and TestSessionCreationFieldProtection.
SE-1. Start POST /surveys/{survey_id}/sessions/start (internal id only):
- 404 if the survey does not exist. No status, membership or
is_activecheck (inferred risk). - If the user has an
in_progresssession for this survey, it is returned unchanged. attempt_number = (count of this user's completed sessions for the survey) + 1. Abandoned sessions do not count.- Cooldown (SE-6, session flavour): when
min_days > 0and a completed session exists, the latestcompleted_at + min_daysmust be past, else 400 (not 409) with the same message as RS-4. - The new session:
status in_progress,progress_percentage 0,responses {},time_spent_seconds 0,current_question_index 0,started_at = last_activity_at = now,incomplete_reminders_sent 0. Client-supplied reminder fields are never accepted (test_incomplete_reminders_sent_not_settable_on_create).
SE-2. Save PUT /sessions/{id}/save: 400 if the session is not in_progress. Merges responses key by key into the stored ones (a key sent as null overwrites), sets any of progress_percentage, time_spent_seconds, current_question_index, status that are present, and last_activity_at = now. When responses is present, progress_percentage is recomputed as answered / question_count * 100 where answered counts values that are not null (so "" counts as answered), 0 when the survey has no questions. Status can be set to completed or abandoned through save, bypassing SE-3 (inferred risk).
SE-3. Complete POST /sessions/{id}/complete with an optional body of final responses: 400 if not in_progress; merges the final responses, sets status completed, completed_at = last_activity_at = now, progress_percentage 100, and inserts the unscored response copy of RS-9.
SE-4. Fix for TypeScript: complete validates (QT-2), scores and inserts the one response through the same service as respond, and respond with a session id completes that session. Both carry an idempotency key from the client so a retried submit never creates a second attempt.
SE-5. Abandon POST /sessions/{id}/abandon: 400 if not in_progress; sets status abandoned. Abandoned and completed sessions cannot be saved or resumed; a new start creates a new session. There is no automatic expiry or abandonment (the model's 24 hour can_resume is unused).
SE-6. The attempt gate. Three independent checks exist today and they can disagree:
| Where | Keyed on | Window starts at | Status code |
|---|---|---|---|
respond (RS-4) |
exact respondent_email |
latest response submitted_at |
409 |
sessions/start (SE-1) |
user_id |
latest completed session completed_at |
400 |
GET /surveys/{id}/can-attempt (SE-7) |
user_id |
latest completed session completed_at |
200 with can_attempt false |
min_days_between_attempts defaults to 0 (gate off); a survey opts in with a positive value (test_attempt_gate.py, "the 30-day retake gate is off by default"). TypeScript keeps one gate function keyed on the respondent identity (user id when signed in, else normalised email), used by all three, with the 409 status everywhere; the contract suite records the Python statuses so the change is visible.
SE-7. Can attempt GET /surveys/{id}/can-attempt returns {can_attempt, attempts_used (completed sessions), min_days_between_attempts, next_eligible_date (ISO or null), has_active_session, active_session_id, reason}. In production the stored _id is stripped on read, so active_session["_id"] would raise when an active session exists (inferred: a 500; tests run on the in-memory store, which keeps _id).
SE-8. Other reads: GET /sessions/{id}; GET /surveys/{id}/sessions (newest attempt first); GET /users/me/sessions?status_filter= (latest activity first); GET /surveys/{id}/sessions/stats returning total_attempts, completed_attempts, abandoned_attempts, average_time_spent, average_progress (means over all sessions), best_attempt (the last completed in storage order, not the highest score) and latest_attempt (the last in storage order).
SE-9. Recurrence. Recurrence is two separate mechanisms: assignment cycles (recurrence_enabled plus recurrence_interval_days, RS-7 and RM-3) and the self-enrolled retake drip (min_days_between_attempts plus auto_remind, RM-5). They share no code and no fields.
Spec says otherwise. Survey Recurrence gives a default cooldown of 30 days (code: 0), configurable recurrence reminder intervals (code: fixed [0, 7, 14], RM-5), OVERDUE "Day 45+" (code: on the next engine tick after the third reminder, RM-4), and staleness by updated_at (code: last_activity_at). Survey Sessions describes max attempts, 30 day expiry and resuming abandoned sessions: none exists.
Reminders and drip campaigns
Source: app/services/reminder_engine.py, app/api/assignments/routes.py, app/api/notifications/routes.py, started in app/main.py:132-133. Tests: tests/test_drip_campaigns.py (41), tests/test_notifications.py (9), tests/test_tenant_assignments.py (11), tests/test_org_counts_and_enroll.py (7).
The engine
RM-1. Loop. On application start, one asyncio task runs forever: process_due_assignments, then process_recurrence_reminders, then process_incomplete_sessions, each wrapped so one failure does not stop the others, then sleeps 60 seconds. It runs in every replica, with no lock: two replicas read the same documents in the same minute and both send. The 24 hour guards (RM-3 step 5, RM-5 step 9, RM-6 step 6) are read-then-write and do not prevent a concurrent duplicate. A failed send is not recorded and is retried every 60 seconds with no backoff.
RM-2. TypeScript design (behaviour, not code): a scheduled job, not an in-process loop, that takes a lease (one runner at a time per campaign), claims each send by writing an idempotency key (campaign, target id, reminder number) before calling the mail provider, and passes the same key to the provider. A send whose answer is lost is retried with the same key. See Infrastructure for the scheduler. Preferences (RM-8) are checked for every campaign.
Campaign 1: assignment reminders
RM-3. process_due_assignments (reminder_engine.py:60-127) over assignments with status PENDING and next_due_date <= now:
- Skip if the survey or the user no longer exists, or preferences say skip (RM-8).
intervals = survey.reminder_intervals(default[0, 3, 7]),sent = assignment.reminders_sent.- If
sent >= len(intervals): setstatus OVERDUE, no email. - Else the next reminder is due at
next_due_date + intervals[sent]days; if not yet, skip. - Skip if
last_reminder_atis less than 24 hours ago. - Send reminder number
sent + 1(subject and body by level 1, 2, 3, clamped) to the user's email, in the user's locale when enabled, else English; linkFRONTEND_URL/survey/{public_id}; unsubscribe link when the user has a preferences document. - On success set
reminders_sent = sent + 1,last_reminder_at = now.
RM-4. Edges of campaign 1:
- A non-recurring assignment never reminds. Assignment creation sets
next_due_dateonly for recurring surveys (assignments/routes.py:74-76); withnullthe query never matches (inferred from code, no test). - For a recurring survey the first reminder is due one full interval after assignment, not at assignment.
- OVERDUE is set on the first engine pass after the last reminder was sent, about a minute later, not after a grace period.
- Survey status and
is_activeare not checked: drafts and closed surveys remind.
RM-5. Campaign 2: recurrence (retake) reminders (reminder_engine.py:130-251) over responses:
- First, every response with
recurrence_reminders_sent == 3and norecurrence_status OVERDUEgetsrecurrence_status OVERDUE(no email). Tests:TestRecurrenceOverdueFlagging(4). - Then, over responses with
recurrence_reminders_sent < 3: skip when there is norespondent_email. - Skip when
next_eligible_dateis null (cooldown 0 never reminds,test_zero_min_days_no_eligible_date) or in the future. - Skip when the same email has a later response to the same survey (they retook).
- Skip when a user with that email has an assignment to this survey (campaign 1 owns them).
- Skip when the survey is gone or
auto_remindis false. - For a known user apply RM-8; for an unknown email the unsubscribe link is a
mailto:toEMAIL_FROMasking to unsubscribe that address. - Thresholds are fixed at
[0, 7, 14]days afternext_eligible_date(the survey'sreminder_intervalsare not used); skip ifnowis before the current threshold. - Skip if the last recurrence reminder is less than 24 hours old.
- Send level
sent + 1with "days since last attempt" ((now - submitted_at).days), then setrecurrence_reminders_sentandlast_recurrence_reminder_at. The OVERDUE flag on a response is not read by the compliance view (CO-2), so a self-enrolled overdue respondent is visible nowhere (inferred).
RM-6. Campaign 3: incomplete sessions (reminder_engine.py:254-326) over sessions in_progress:
- Skip when
last_activity_atis missing or less than 24 hours ago, or 3 reminders were sent. - Skip when the user or their email or the survey is missing, or RM-8 says skip.
- Thresholds by level: 24, 48 and 168 hours since
last_activity_at. - Skip if below the threshold for the next level.
- Skip if the last incomplete reminder is less than 24 hours old.
- Send with the integer progress percentage, then set
incomplete_reminders_sentandlast_incomplete_reminder_at. Tests:TestIncompleteDrip(9),TestIncompleteDripEdgeCases(6).
Assignments and admin actions
All admin actions need OWNER or ADMIN of the organization in the path.
RM-7.
- Assign
POST /organizations/{org}/surveys/{survey}/assignwithmember_ids: 404 when the survey is not in that org. Each id that is not a member of the org, or already assigned to this survey, is skipped and counted. A new assignment isPENDING,reminders_sent 0,current_due_date nowandnext_due_date now + intervalonly for recurring surveys, else both null. Returns{message, assigned_count, skipped_count, assignments}. No email is sent on assignment. - Unassign
DELETE .../assign/{member}: 204, or 404 when absent. Responses are kept. - List
GET .../assignments: any member; a RESPONDENT sees only their own. Includes member email and name.GET /me/assignmentslists the caller's own across organizations with survey and organization names, skipping orphans. - Overdue
GET /organizations/{org}/overdue: assignments withstatus OVERDUE, withdays_overdue = max(0, whole days since next_due_date). - Extend
POST .../assign/{member}/extendwithdays(1 to 365, default 7):next_due_date = (next_due_date or now) + days,status PENDING,reminders_sent 0,last_reminder_at null. - Waive
POST .../waive: recurring survey:status PENDING,current_due_date now,next_due_date now + interval; otherwisestatus WAIVED. Both resetreminders_sentandlast_reminder_at. - Remind
POST .../remind: sends at once, ignoring schedule and preferences, levelmin(reminders_sent + 1, 3), always English, no unsubscribe link, then stores that level andlast_reminder_at now. Test:TestManualRemindIncrementsCount. No custom message. - Tenant enrollment (
app/services/enrollment.py): a user signing in through an organization's custom domain gets a RESPONDENT membership if they have none (an existing role is never changed) and a system assignment (assigned_by "system") to every published active survey of that org they are not assigned to and have not answered (by user id or email). Idempotent. Details of the sign-in side are in Auth.
Preferences, mute and unsubscribe
RM-8. Skip rules (reminder_engine.py:23-50), read from the user's notification_preferences document; no document means send:
email_reminders_enabledfalse, orsurvey_remindersfalse, or the survey id inmuted_survey_ids: skip.- Quiet hours when
quiet_hours_enabled: the current time in the user'stimezone(default UTC) asHH:MMis compared as text withquiet_hours_start(default22:00) andquiet_hours_end(default08:00); a window crossing midnight is handled. If the stored start or end isnullthe comparison raises and the error is swallowed, so no quiet hours apply (inferred from code). Skipped reminders are not deferred; they go out on a later pass once outside the window. reminder_frequency(default,daily_digest,weekly_digest,never) is stored and ignored by the engine.
RM-9. Endpoints (app/api/notifications/routes.py):
GET /notifications/preferencescreates the document with defaults and a randomunsubscribe_tokenon first read. Defaults: all three switches on, frequencydefault, quiet hours off, timezone UTC, no mutes.PUT /notifications/preferencesreplaces all fields with the body (missing fields reset to defaults).POST /notifications/preferences/mute-survey/{id}andunmute-survey/{id}: idempotent; survey existence is not checked.GET /notifications/unsubscribe?token=(no auth): 404 for an unknown token, else turns off all three switches. A state change on GET: a mail scanner following the link unsubscribes the user (inferred risk). TypeScript keeps the GET for old links and adds a confirm step plus one-click POST (RFC 8058).- An unsubscribe link exists only for users who have opened their preferences once, since the token is created then.
Respondent profile and compliance
Tests: tests/test_respondent_profile.py (5), tests/test_response_context.py (7), tests/test_save_at_end.py (2), tests/test_my_history.py (7), tests/test_compliance.py (8).
PR-1. Organization profile_config (app/models/organization_enhanced.py:104-125, app/api/organizations/routes.py:267-298): supported fields email, geo, name, each {enabled, required}. Default email {true, true}, geo {false, false}, name {false, false}. PUT /organizations/{id}/profile-config (OWNER or ADMIN) replaces the whole config with the fields sent (sending only geo drops the email and name entries); an unsupported field is 400 Unsupported profile field: x. The public survey read includes the org's profile_config so the take page knows what to collect. Enforcement at submit is RS-3.
PR-2. Geo is stored on the response as {country, region}, decoupled from answers (never inside responses). country is free text (the take page sends a country name such as Canada, inferred from frontend/src/components/ProfileStep.tsx), not validated against a list. Legacy responses that kept a COUNTRY answer inside responses still read (test_historical_response_with_country_answer_still_reads).
PR-3. Response context {event (max 100), description (max 1000)}, both optional; longer is 422. Stored and returned as given (app/models/survey.py:276-284).
PR-4. Claim POST /surveys/responses/{response_id}/claim (signed in): 404 when the response does not exist; 409 Response already belongs to an account when it has a user_id; else sets user_id to the caller and overwrites respondent_email with the caller's email. Any signed-in user who knows an unclaimed response id can claim it; no proof that they were the guest is required. Fix: TypeScript requires a claim token issued with the guest submission (see Auth).
PR-5. Own history GET /surveys/{id or public_id}/my-responses?limit= (1 to 200, default 50): responses of that survey where user_id is the caller or respondent_email equals the caller's email exactly, newest first; no membership needed. DELETE /surveys/{id}/my-responses/{rid}: 404 for an unknown survey or response, 403 Not your response when neither user id nor email matches.
CO-1. Compliance GET /surveys/{id}/compliance (OWNER or ADMIN, routes.py:841-934), one row per member of the survey's organization (users that no longer exist are skipped):
attempts, baseline and latest: the responses to this survey whoserespondent_emailequals the member's email exactly, sorted bysubmitted_at. Responses linked only byuser_idare not counted.baseline_score= firstscore,latest_score= lastscore,delta = pyRound(latest - baseline, 2)only when there are at least 2 attempts and both scores are non-null.last_taken= lastsubmitted_at.next_due=last_taken + min_dayswhenmin_days > 0, else the assignment'snext_due_date.reminders_sentfrom the assignment, else 0.
CO-2. Compliance status, first match wins:
| Order | Status | Condition |
|---|---|---|
| 1 | INCOMPLETE |
the member has an in_progress session for this survey |
| 2 | OVERDUE |
assignment status OVERDUE |
| 3 | DUE |
next_due set and now >= next_due |
| 4 | WAIVED |
assignment status WAIVED |
| 5 | COMPLETED |
at least one attempt |
| 6 | PENDING |
an assignment exists |
| 7 | NOT_ASSIGNED |
otherwise |
CO-3. Respondent history GET /surveys/{id}/respondent/{email}/history (OWNER or ADMIN): that exact email's responses sorted oldest first, each with attempt_number (1 based), submitted_at, score, overall_percentage, group_results, responses.
CO-4. Email matching. Every email-keyed rule (RS-4, RM-5, PR-5, CO-1, CO-3, impact grouping) compares the stored string exactly. Fix: TypeScript normalises emails to lower case on write, and the migration lower-cases stored respondent emails, which may merge two respondents into one; the migration report lists every merge.
Spec says otherwise. Survey Recurrence lists the compliance view for "every assigned member"; the code lists every member of the organization, assigned or not. Organizations says assigned members "receive an email notification" and manual reminders take a custom message: neither exists (RM-7).
Impact comparison and reports
Impact
Source: app/services/impact.py, app/api/impact/routes.py (mounted under /surveys). Tests: tests/test_impact.py (13).
IM-1. Access: GET /surveys/{id}/impact, OWNER or ADMIN of the survey's organization; 404 for an unknown survey; computed on every request.
IM-2. Respondents are grouped by user_id, else respondent_email; responses with neither are ignored. The same person answering once as a guest (email) and once signed in (user id) is two respondents. Each respondent's responses are sorted by submitted_at, else completed_at, else the earliest possible time. One response: excluded and counted in respondents_excluded. Two or more: the pair (first, last); intermediate attempts are unused.
IM-3. Insufficient data: when no question has a scoring config or no respondent has two attempts, return baseline_avg_score, latest_avg_score, overall_delta, overall_delta_percent all null, per_question [], and the summary Insufficient data: respondents need at least 2 attempts to calculate impact.
IM-4. Per question, for each scored question in order:
b= the baseline responses'score_breakdown[qid]values where present;llikewise for latest. If either list is empty, the question is skipped. The two lists may have different lengths and different respondents.baseline_avg = mean(b),latest_avg = mean(l),delta = latest_avg - baseline_avg(unrounded).delta_percent = pyRound(delta / baseline_avg * 100, 1)whenbaseline_avg > 0, else null.direction:improvedwhendelta > 0.01,declinedwhendelta < -0.01, elseunchanged.- Output
baseline_avg,latest_avg,deltaeachpyRound(x, 2).
IM-5. Overall: baseline_avg_score = mean of every per-question baseline value pooled across questions (raw points, so a question scored 0 to 10 outweighs one scored 0 to 5); latest_avg_score likewise; overall_delta = pyRound(latest - baseline, 2); overall_delta_percent = pyRound(overall_delta / baseline * 100, 1) using the already rounded delta, null when baseline is 0. Outputs rounded to 2.
IM-6. Summary text, English only whatever the export locale:
Overall impact {improved by P% | declined by P% | remained unchanged} across N respondent(s) with multiple attempts.(singular when N is 1; "unchanged" when the percent is null or 0).- If any question improved:
Strongest improvement in "{text}" (+{delta with 1 decimal}).for the largest rounded delta (first on ties). - If any declined:
"{text}" declined by {abs delta, 1 decimal}.for the smallest. - If any were excluded:
{k} respondent(s) excluded (only 1 attempt).computed_atis the UTC time in ISO format withZ.
Spec says otherwise. Impact Comparison defines the overall delta as the mean of per-respondent normalised deltas (sum earned / sum max); the code pools raw per-question averages (IM-5). The spec's per-respondent breakdown is not returned by the API.
Exports
Source: app/services/report_export.py. Tests: tests/test_report_export.py (31), tests/test_export_endpoints.py (5).
EX-1. Endpoints, all OWNER or ADMIN, ?lang= falling back to en when the locale is not enabled (not to the user's locale):
| Path | Content | Filename |
|---|---|---|
GET /surveys/{id}/export.csv |
impact CSV | {title slug}.csv (EX-6) |
GET /surveys/{id}/report.pdf |
impact PDF | {title slug}.pdf |
GET /surveys/{id}/responses/export.csv |
responses CSV | sondely-responses-{slug}-{YYYY-MM-DD}.csv |
GET /organizations/{id}/members/export.csv |
members CSV | sondely-members-{slug}-{YYYY-MM-DD}.csv (inferred kind name) |
EX-2. CSV conventions for all three: UTF-8 with a BOM, standard CSV quoting, labels localised (en, fr, es; other locales fall back to English labels), numbers with a dot whatever the locale. User-entered text (titles, question text, emails, names, text answers) is prefixed with ' when it starts with =, +, -, @, tab or carriage return; numbers the code formats itself are never prefixed (a negative delta stays -0.40). Floats print with 2 decimals; integers as is; null as empty. Note for TypeScript: JavaScript has no int and float distinction, so "integer" means Number.isInteger on the parsed value, and a stored 4.0 prints 4 instead of 4.00 (accepted divergence, NU-3).
EX-3. Impact CSV: rows Survey, {title}; Respondents, n; With multiple attempts, n; Baseline average; Latest average; Change; an empty row; header Question, Baseline average, Latest average, Delta, Delta %, Direction; one row per question. French labels: Sondage, Répondants, Avec plusieurs tentatives, Moyenne de référence, Moyenne récente, Variation, Question, Écart, Écart %, Tendance. The direction value is the English keyword.
EX-4. Responses CSV: header Email, Name, Submitted at, Score, then one column per question by order titled with the question text (source language); one row per response sorted by the ISO text of submitted_at, oldest first. Answer cells: boolean true / false; list joined with ; ; an object with country as country, region (or just country); any other object as its Python text form (inferred: never produced by the UI); timestamps ISO 8601 without zone.
EX-5. Members CSV: Email, Name, Role, Joined (Joined ISO 8601).
EX-6. Filenames. Responses and members use sondely-{kind}-{slug}-{date}.{ext}: survey title (organization name for members) accent-folded to ASCII, non-alphanumerics to -, runs collapsed, trimmed, at most 60 characters, lower case; date is the server's current date. The impact CSV and PDF use another slug: every character that is not Unicode alphanumeric, - or _ becomes -, truncated to 60, case kept, no prefix, no date, and accented letters are kept in the header. Fix: TypeScript uses the first form for all four (sondely-impact-report-..., as the export spec asks), with RFC 5987 filename*.
EX-7. Impact PDF (reportlab, US Letter). TypeScript cannot match it byte for byte and does not try; the contract is the text and structure, asserted by extracting the text:
- Optional logo: fetched only for
httpandhttpsURLs, 3 second timeout, scaled to at most 0.5 inch high and 2 inch wide; any failure renders no logo. The fetch follows redirects to any host (inferred risk: server-side request to an organization-controlled URL). Fix: TypeScript fetches through an allow list or from uploaded storage only. - Organization name (brand colour, default
#00796Bwhen missing or invalid), titleImpact Report(frRapport d'impact, esInforme de impacto),Survey: {title}. - Summary heading and the IM-6 text when non-empty.
- A two-column table: Respondents, With multiple attempts, Baseline average, Latest average, Change.
- When questions exist, a table
Question, Baseline average, Latest average, Deltawith a brand-coloured header whose cells wrap (French labels must not run together:test_long_french_headers_do_not_collide). - Footer
Generated by Sondely. - Numbers with 2 decimals, and a comma decimal in
frandes(2,90), dots otherwise. - Text is placed into reportlab paragraphs unescaped, so a title containing
<can break rendering (inferred). TypeScript escapes.
Spec says otherwise. Report Export gives paths under /api/v1/impact/... (code: /api/v1/surveys/...), says BOM, injection guard, responses CSV, members CSV and the logo are still missing (all shipped), and lists PDF content the code does not render: generation date, period covered, excluded count, delta percent, direction, respondent count, paired bars, page numbers. lang defaults to en, not the user's locale.
Dashboard stats
Source: app/api/dashboard/routes.py. Tests: tests/test_dashboard.py (10).
DB-1. GET /dashboard/stats returns the admin shape when the user is OWNER or ADMIN anywhere or has no membership at all, else the respondent shape.
DB-2. Admin shape: organizations (the user's, with live member_count and the user's role), stats.active_surveys (PUBLISHED among visible surveys, LC-8), stats.total_responses, stats.total_members (sum over the user's organizations, so a person in two organizations counts twice), stats.avg_impact_score = pyRound(mean of every non-null response score across visible surveys, 1). This averages raw totals from surveys on different scales (a Maslow total next to a 0 to 60 flat total). recent_surveys: the 10 newest with response counts.
DB-3. Respondent shape: is_respondent true, organizations, and my_surveys: one card per survey the user answered (by email or user id) or is assigned to, with attempts, latest_score, delta (2 decimals, at least 2 attempts), last_taken, next_due and can_take from min_days_between_attempts (also read from settings.min_days_between_attempts for older surveys).
Survey content translation
Customer content (titles, groups, questions, options, rating labels), separate from interface catalogs, which move to TourCockpit's system (see Translation). Source: app/services/translation.py, routes at app/api/surveys/routes.py:977-1233, public swap at routes.py:248-282. Tests: tests/test_translation_service.py (32), tests/test_translation_edge_cases.py (32), tests/test_translation_api.py (13).
TR-1. Languages. 33 target languages, English is the source and not a target: es Spanish, fr French, pt Portuguese, ar Arabic, sw Swahili, lg Luganda, hu Hungarian, hi Hindi, ht Haitian Creole, am Amharic, so Somali, my Burmese, zh Chinese (Simplified), bn Bengali, de German, ja Japanese, ko Korean, ru Russian, it Italian, nl Dutch, pl Polish, tr Turkish, vi Vietnamese, th Thai, id Indonesian, ms Malay, tl Filipino, uk Ukrainian, ro Romanian, el Greek, he Hebrew, yo Yoruba, ha Hausa (translation.py:27-61).
TR-2. Translate POST /surveys/{id}/translate with {target_languages: [...], context?} (OWNER or ADMIN):
- Any code outside TR-1 (including
en) is 400Unsupported language: x. No questions: 400. No status or response check. - Extract (
translation.py:95-125):survey_title,survey_description(""when null),sections= groups as{id, name, description},questionsinorderas{id, type, text}plussection(group id) when grouped,optionswhen non-empty,rating_labelswhen present. - Languages are processed one after the other. For each: build the prompt (TR-3), call the model (TR-4), validate (TR-5). A failure or a validation error records
errors[lang]and moves on. - Store each success:
survey.translations[lang] = {title, description, groups?: {group_id: {name, description}}},translation_metadata[lang] = {status: "draft", translated_at},langappended toavailable_languages, andquestion.translations[lang] = {text, options?, rating_labels?}. Re-translating overwrites earlier text, including manual edits, and resets the status to draft. - Response 200 even when every language failed:
{available_languages, default_language, languages: {code: {status, translated_at, title, question_count}}}, with failed languages as{status: "failed", errors: [...]}.
TR-3. Prompt (translation.py:189-211): a system message for a professional translator of nonprofit impact surveys, the optional context, and rules: translate from English to the target language name, simple everyday language for limited literacy, culturally appropriate, concise, standard equivalents for rating labels, options translated in context keeping order and count, id, type and section preserved, only the listed fields translated, same JSON structure. The user message is the extracted content as indented JSON.
TR-4. Model call: Azure OpenAI chat completions, POST {AZURE_OPENAI_ENDPOINT}/openai/deployments/{AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version={AZURE_OPENAI_API_VERSION}, defaults https://openai-ideaplaces.openai.azure.com, gpt-4.1, 2024-12-01-preview (production overrides the endpoint to the CatalyzeUp resource per the code comment, inferred). Header api-key; temperature 0.3; response_format {type: "json_schema", json_schema: {name: "survey_translation", strict: false, schema}} where the schema mirrors TR-2 step 2 with additionalProperties false. Timeout 120 seconds. The key comes from AZURE_OPENAI_KEY, else from az cognitiveservices account keys list at run time; the TypeScript version reads Key Vault only. Token usage is logged.
TR-5. validate_translation (translation.py:263-298), errors as a list:
survey_titlemissing or empty.survey_titleequal to the original (treated as untranslated; a title that is legitimately identical in the target language fails).- Section count differs, else a section
iddiffers at the same index. - Question count differs, else at the same index:
iddiffers,typediffers, option count differs (checked only when both sides have options, so dropped options pass), rating label key set differs (only when both sides have labels). Question text, option text and description are not checked for emptiness or for being untranslated.
TR-6. Edit or approve PATCH /surveys/{id}/translations/{lang} with {survey?, questions?: {qid: {...}}, status?}: 404 when lang is not in available_languages (the default en is in it, so en can be "edited"). Survey and question fields are merged key by key. status is any string (the pending, draft, approved enum is not applied); approved also stores approved_at and approved_by. Question ids are looked up without checking that they belong to this survey, so an admin of one organization can write translations into another organization's question (inferred from routes.py:1124-1134). Fix: TypeScript scopes by survey.
TR-7. Remove DELETE /surveys/{id}/translations/{lang}: 400 for the default language, 404 when absent; removes the language from the survey, its metadata, available_languages and every question.
TR-8. Respondent view GET /surveys/public/{public_id}?lang=: when lang differs from the default, is in available_languages and has a survey translation, swap title, description (when not null), group name and description, and per question text, rating labels and options, keeping the source options in original_options. The swap ignores the translation status: draft translations are shown to respondents. An unknown or untranslated language silently returns the source. Responses always store source keys (QT-1).
Spec says otherwise. Multi-Language specifies a single target_language with auto_approve, the Claude API, title_translations and option_translations fields, and "always draft until approved" before respondents see it. The code takes a list, calls Azure OpenAI, stores per-language objects, and serves drafts. Whether TypeScript serves only approved translations is a product decision; parity serves drafts.
Geo detection and languages
GE-1. GET /geo/detect (no auth, app/api/geo/routes.py): best guess for the caller, never authoritative. Client IP is the first X-Forwarded-For entry, else the socket address. Order:
- MaxMind GeoLite2 City database at
GEOIP_DB_PATH(default/app/data/GeoLite2-City.mmdb, downloaded at image build with a license key):{country_code: ISO code, region: most specific subdivision name or null, source: "maxmind"}. A missing file, a load error or a lookup miss falls through. CF-IPCountryheader when present and notXXorT1: upper-cased,source: "cloudflare", region null.- ipinfo.io when
IPINFO_TOKENis set, 4 second timeout:source: "ipinfo". - Otherwise
{country_code: null, region: null, source: "none"}. Tests:tests/test_geo_detect.py(5). TypeScript: themaxmindnpm reader over the same file, the database file provisioned as in Infrastructure, and a fake lookup for tests.
LG-1. GET /languages (no auth): {source: "en", enabled: [{code, name}], available: [{code, name}]}. available is English plus the 33 of TR-1. enabled is English plus every locale marked APPROVED in frontend/lang/_status.json (today fr and es), read on every call; a missing file means English only. is_enabled gates user.locale, email locale and export lang. Tests: tests/test_languages.py (6). In TypeScript enabled comes from TourCockpit's catalog status instead (see Translation); the payload shape stays.
Spec and code disagreements
Code wins for the port; each line is a spec page to correct (see the spec-keeper step in the Overview).
| Spec | Says | Code does |
|---|---|---|
| Scoring System | percentage score, max_points, positive_answer, weight_multiplier, qualitative excluded from denominator |
raw sum, none of these fields (SC-10, SC-12) |
| Scoring System | multi_select_sum not built, capped at max |
built, uncapped, unreachable from the builder (SC-9) |
| Maslow Assessment | 5 levels, 20 questions, averages | 6 groups, 24 questions, weight-normalised (TP-3, GR-5) |
| Structured Assessments | POST /surveys/from-template, templates collection, Team Health and Program Impact not yet |
POST /templates/{id}/create-survey, static data, both shipped, nine templates (TP-2, TP-3) |
| Impact Comparison | overall delta is the mean of per-respondent normalised deltas | pooled raw per-question means (IM-5) |
| Impact Comparison | question PUT blocked once responses exist | no question PUT; survey PUT never blocked (LC-3, LC-4) |
| Survey Recurrence | default cooldown 30 days | 0 (SE-6) |
| Survey Recurrence | recurrence reminder intervals configurable | fixed [0, 7, 14] (RM-5) |
| Survey Recurrence | OVERDUE after day 45 | next engine pass after the third send (RM-4) |
| Survey Recurrence | staleness by updated_at |
last_activity_at (RM-6) |
| Survey Recurrence | compliance lists assigned members | lists all organization members (CO-1) |
| Survey Sessions | max attempts, 30 day expiry, resume abandoned | none (SE-5) |
| Organizations | email on assignment, custom manual message | neither (RM-7) |
| Organizations | reminders on the due date for every assignment | non-recurring assignments never remind (RM-4) |
| Report Export | /api/v1/impact/... paths; BOM, injection guard, responses and members CSV, logo missing |
/api/v1/surveys/...; all shipped (EX-1, EX-2) |
| Report Export | dated sondely-impact-report-... filenames, richer PDF |
undated title slug, smaller PDF (EX-6, EX-7) |
| Multi-Language | single language, auto-approve, Claude, drafts hidden | list, Azure OpenAI, drafts served (TR-2, TR-4, TR-8) |
Open decisions
- Serve draft content translations to respondents (parity) or approved only (TR-8).
- Non-integer ratings: accept (parity) or refuse (QT-2 rule 4).
- Group results for unscored or
nullmax questions (GR-3): parity, or exclude them from the maximum, which changes stored Maslow-style percentages for surveys built in the UI. If changed, storedgroup_resultsare kept as they were and only new responses use the new rule. - Whether the stored config keeps
nullversus missing (SC-3), or the migration normalises it and the parity check runs on normalised data.
Python test files to port as TypeScript tests
Counts are test functions (def test_) in each file at 06debbf; only test_group_color.py uses parametrisation (one case list). Targets: unit (pure TypeScript function, Vitest, no database), integration (TypeScript service or route against Postgres), contract (HTTP against /api/v1, run against Python first and then TypeScript, see Testing and Bugs). Priority 1 is the table-driven scoring and assessment logic, ported first and kept as data tables shared by the unit and contract layers.
| Priority | Python file | Tests | Target | Notes |
|---|---|---|---|---|
| 1 | tests/test_scoring.py |
27 | 22 unit, 5 contract | SC-1 to SC-11 |
| 1 | tests/test_scoring_comprehensive.py |
38 | 36 unit, 2 contract | boolean, rating, choice tables; NU-2 vectors added |
| 1 | tests/test_structured_assessments.py |
53 | about 19 Zod schema unit, 8 aggregation unit, about 26 contract | GR-1 to GR-7, TP-3 weights snapshot |
| 1 | tests/test_survey_builder_v2.py |
33 | contract | group scoring, require email, cooldown, preview |
| 1 | tests/test_templates.py |
7 | contract | TP-1, TP-2 |
| 1 | tests/test_group_color.py |
5 | unit | GR-7 |
| 1 | tests/test_impact.py |
13 | 7 unit, 6 contract | IM-1 to IM-6 |
| 2 | tests/test_drip_campaigns.py |
41 | integration | RM-3 to RM-8 with a fake clock and fake mail; add a lease test for RM-2 |
| 2 | tests/test_attempt_gate.py |
4 | contract | SE-6 |
| 2 | tests/test_surveys.py |
50 | contract (concurrency cases as integration) | LC-1 to LC-7, RS-1 |
| 2 | tests/test_compliance.py |
8 | contract | CO-1 to CO-3 |
| 2 | tests/test_respondent_profile.py |
5 | contract | PR-1, PR-2, RS-3 |
| 2 | tests/test_response_context.py |
7 | 3 contract | PR-3; the 4 backfill tests are one-off migration code, dropped |
| 2 | tests/test_save_at_end.py |
2 | contract | PR-4, plus a new test for the claim token fix |
| 2 | tests/test_my_history.py |
7 | contract | PR-5 |
| 2 | tests/test_tenant_assignments.py |
11 | integration | RM-7 step 8 |
| 2 | tests/test_org_counts_and_enroll.py |
7 | contract | membership counts, enrollment |
| 2 | tests/test_notifications.py |
9 | contract | RM-9 |
| 3 | tests/test_translation_service.py |
32 | unit | TR-2 extraction, schema, TR-5 validator, mapping |
| 3 | tests/test_translation_edge_cases.py |
32 | about 14 unit, about 18 contract | lifecycle, merges, swap, permissions; model faked |
| 3 | tests/test_translation_api.py |
13 | contract with the fake model mode | TR-2, TR-6 to TR-8 |
| 3 | tests/test_report_export.py |
31 | unit | CSV byte content exact; PDF by extracted text (EX-7) |
| 3 | tests/test_export_endpoints.py |
5 | contract | EX-1, headers and filenames |
| 3 | tests/test_dashboard.py |
10 | contract | DB-1 to DB-3 |
| 3 | tests/test_languages.py |
6 | contract | LG-1 shape; enablement source changes |
| 3 | tests/test_geo_detect.py |
5 | unit with a fake reader | GE-1 |
| 3 | tests/unit/test_organization_model.py |
26 | Zod schema unit, only the cases for fields still in use | organization_enhanced.py is live |
| Drop | tests/unit/test_session_model.py |
22 | none | tests app/models/session.py, imported by no route |
| Drop | tests/unit/test_reminder_model.py |
24 | none | tests app/models/reminder.py, imported by no route |
| Drop | tests/test_migration.py, tests/test_backfill_geo.py |
41, 8 | none | one-off data migrations |
| Elsewhere | tests/test_ui_translation.py, tests/test_email_localization.py |
12, 14 | see Translation | interface catalogs move to TourCockpit's system |
| Elsewhere | auth, OTP, OAuth, API key, invitation, tenant membership, custom domain files | see Auth |
New tests with no Python counterpart, written failing first: NU-1 and NU-2 vectors; every QT-2 rule; SC-3 null versus absent; RM-2 duplicate send across two runners; RS-9 single response per attempt; PR-4 claim token; TR-6 cross-survey write refused; CO-4 email normalisation.