CatalyzeUp
sondely/migration

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, branch develop at 06debbf (21 September 2026), directory backend/. Paths below are relative to backend/ unless they start with frontend/.
  • 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:

  1. Every key in responses is the id of a question of this survey. Unknown keys are refused (today they are stored).
  2. A required question has a non-empty answer (null, missing and "" count as empty). Today only the browser checks this (SurveyTakePage.tsx:645-651). Sessions save is exempt: partial answers are its purpose.
  3. BOOLEAN: a JSON boolean.
  4. RATING: a finite number within [scoring_config.min_value ?? 0, scoring_config.max_value ?? 5]. Strings, NaN and 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.
  5. SINGLE_CHOICE: a string equal to one of options (exact match on write; the case-insensitive match in SC-8 stays for scoring stored data).
  6. MULTIPLE_CHOICE: an array of distinct strings, each one of options.
  7. TEXT: a string; a maximum length is to be set (inferred: none exists anywhere today).
  8. 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:

  1. binary with true_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.
  2. Group aggregation reads max_value with 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 between null and 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):

  1. breakdown[question_id] exists for every question with a scoring config, answered or not.
  2. total is the plain sum of the breakdown, in question iteration order, not rounded and not normalised. It is raw points, not a percentage.
  3. If no question has a scoring config, both are null. Test: test_no_scoring_config_returns_none.
  4. Unscored questions (scoring_config null) 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 * weight when max_raw is 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):

  1. Unknown template: 404. Unknown organization: 400 Organization not found. Caller not OWNER or ADMIN of it: 403.
  2. Creates a DRAFT survey with new cuid id and public_id, title and description from 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_type and question_groups copied from the template (group ids kept as in the template, for example vital).
  3. Questions copied in template order with order = index, required default true, weight default 1.0, scoring_config, group_id and rating_labels copied as raw dicts (SC-3).
  4. 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):

  1. 404 if the survey does not exist. No status, membership or is_active check (inferred risk).
  2. If the user has an in_progress session for this survey, it is returned unchanged.
  3. attempt_number = (count of this user's completed sessions for the survey) + 1. Abandoned sessions do not count.
  4. Cooldown (SE-6, session flavour): when min_days > 0 and a completed session exists, the latest completed_at + min_days must be past, else 400 (not 409) with the same message as RS-4.
  5. 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:

  1. Skip if the survey or the user no longer exists, or preferences say skip (RM-8).
  2. intervals = survey.reminder_intervals (default [0, 3, 7]), sent = assignment.reminders_sent.
  3. If sent >= len(intervals): set status OVERDUE, no email.
  4. Else the next reminder is due at next_due_date + intervals[sent] days; if not yet, skip.
  5. Skip if last_reminder_at is less than 24 hours ago.
  6. 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; link FRONTEND_URL/survey/{public_id}; unsubscribe link when the user has a preferences document.
  7. On success set reminders_sent = sent + 1, last_reminder_at = now.

RM-4. Edges of campaign 1:

  1. A non-recurring assignment never reminds. Assignment creation sets next_due_date only for recurring surveys (assignments/routes.py:74-76); with null the query never matches (inferred from code, no test).
  2. For a recurring survey the first reminder is due one full interval after assignment, not at assignment.
  3. OVERDUE is set on the first engine pass after the last reminder was sent, about a minute later, not after a grace period.
  4. Survey status and is_active are not checked: drafts and closed surveys remind.

RM-5. Campaign 2: recurrence (retake) reminders (reminder_engine.py:130-251) over responses:

  1. First, every response with recurrence_reminders_sent == 3 and no recurrence_status OVERDUE gets recurrence_status OVERDUE (no email). Tests: TestRecurrenceOverdueFlagging (4).
  2. Then, over responses with recurrence_reminders_sent < 3: skip when there is no respondent_email.
  3. Skip when next_eligible_date is null (cooldown 0 never reminds, test_zero_min_days_no_eligible_date) or in the future.
  4. Skip when the same email has a later response to the same survey (they retook).
  5. Skip when a user with that email has an assignment to this survey (campaign 1 owns them).
  6. Skip when the survey is gone or auto_remind is false.
  7. For a known user apply RM-8; for an unknown email the unsubscribe link is a mailto: to EMAIL_FROM asking to unsubscribe that address.
  8. Thresholds are fixed at [0, 7, 14] days after next_eligible_date (the survey's reminder_intervals are not used); skip if now is before the current threshold.
  9. Skip if the last recurrence reminder is less than 24 hours old.
  10. Send level sent + 1 with "days since last attempt" ((now - submitted_at).days), then set recurrence_reminders_sent and last_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:

  1. Skip when last_activity_at is missing or less than 24 hours ago, or 3 reminders were sent.
  2. Skip when the user or their email or the survey is missing, or RM-8 says skip.
  3. Thresholds by level: 24, 48 and 168 hours since last_activity_at.
  4. Skip if below the threshold for the next level.
  5. Skip if the last incomplete reminder is less than 24 hours old.
  6. Send with the integer progress percentage, then set incomplete_reminders_sent and last_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.

  1. Assign POST /organizations/{org}/surveys/{survey}/assign with member_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 is PENDING, reminders_sent 0, current_due_date now and next_due_date now + interval only for recurring surveys, else both null. Returns {message, assigned_count, skipped_count, assignments}. No email is sent on assignment.
  2. Unassign DELETE .../assign/{member}: 204, or 404 when absent. Responses are kept.
  3. List GET .../assignments: any member; a RESPONDENT sees only their own. Includes member email and name. GET /me/assignments lists the caller's own across organizations with survey and organization names, skipping orphans.
  4. Overdue GET /organizations/{org}/overdue: assignments with status OVERDUE, with days_overdue = max(0, whole days since next_due_date).
  5. Extend POST .../assign/{member}/extend with days (1 to 365, default 7): next_due_date = (next_due_date or now) + days, status PENDING, reminders_sent 0, last_reminder_at null.
  6. Waive POST .../waive: recurring survey: status PENDING, current_due_date now, next_due_date now + interval; otherwise status WAIVED. Both reset reminders_sent and last_reminder_at.
  7. Remind POST .../remind: sends at once, ignoring schedule and preferences, level min(reminders_sent + 1, 3), always English, no unsubscribe link, then stores that level and last_reminder_at now. Test: TestManualRemindIncrementsCount. No custom message.
  8. 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:

  1. email_reminders_enabled false, or survey_reminders false, or the survey id in muted_survey_ids: skip.
  2. Quiet hours when quiet_hours_enabled: the current time in the user's timezone (default UTC) as HH:MM is compared as text with quiet_hours_start (default 22:00) and quiet_hours_end (default 08:00); a window crossing midnight is handled. If the stored start or end is null the 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.
  3. reminder_frequency (default, daily_digest, weekly_digest, never) is stored and ignored by the engine.

RM-9. Endpoints (app/api/notifications/routes.py):

  1. GET /notifications/preferences creates the document with defaults and a random unsubscribe_token on first read. Defaults: all three switches on, frequency default, quiet hours off, timezone UTC, no mutes.
  2. PUT /notifications/preferences replaces all fields with the body (missing fields reset to defaults).
  3. POST /notifications/preferences/mute-survey/{id} and unmute-survey/{id}: idempotent; survey existence is not checked.
  4. 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).
  5. 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):

  1. attempts, baseline and latest: the responses to this survey whose respondent_email equals the member's email exactly, sorted by submitted_at. Responses linked only by user_id are not counted.
  2. baseline_score = first score, latest_score = last score, delta = pyRound(latest - baseline, 2) only when there are at least 2 attempts and both scores are non-null.
  3. last_taken = last submitted_at. next_due = last_taken + min_days when min_days > 0, else the assignment's next_due_date.
  4. reminders_sent from 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:

  1. b = the baseline responses' score_breakdown[qid] values where present; l likewise for latest. If either list is empty, the question is skipped. The two lists may have different lengths and different respondents.
  2. baseline_avg = mean(b), latest_avg = mean(l), delta = latest_avg - baseline_avg (unrounded).
  3. delta_percent = pyRound(delta / baseline_avg * 100, 1) when baseline_avg > 0, else null.
  4. direction: improved when delta > 0.01, declined when delta < -0.01, else unchanged.
  5. Output baseline_avg, latest_avg, delta each pyRound(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:

  1. 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).
  2. If any question improved: Strongest improvement in "{text}" (+{delta with 1 decimal}). for the largest rounded delta (first on ties).
  3. If any declined: "{text}" declined by {abs delta, 1 decimal}. for the smallest.
  4. If any were excluded: {k} respondent(s) excluded (only 1 attempt). computed_at is the UTC time in ISO format with Z.

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:

  1. Optional logo: fetched only for http and https URLs, 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.
  2. Organization name (brand colour, default #00796B when missing or invalid), title Impact Report (fr Rapport d'impact, es Informe de impacto), Survey: {title}.
  3. Summary heading and the IM-6 text when non-empty.
  4. A two-column table: Respondents, With multiple attempts, Baseline average, Latest average, Change.
  5. When questions exist, a table Question, Baseline average, Latest average, Delta with a brand-coloured header whose cells wrap (French labels must not run together: test_long_french_headers_do_not_collide).
  6. Footer Generated by Sondely.
  7. Numbers with 2 decimals, and a comma decimal in fr and es (2,90), dots otherwise.
  8. 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):

  1. Any code outside TR-1 (including en) is 400 Unsupported language: x. No questions: 400. No status or response check.
  2. Extract (translation.py:95-125): survey_title, survey_description ("" when null), sections = groups as {id, name, description}, questions in order as {id, type, text} plus section (group id) when grouped, options when non-empty, rating_labels when present.
  3. 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.
  4. Store each success: survey.translations[lang] = {title, description, groups?: {group_id: {name, description}}}, translation_metadata[lang] = {status: "draft", translated_at}, lang appended to available_languages, and question.translations[lang] = {text, options?, rating_labels?}. Re-translating overwrites earlier text, including manual edits, and resets the status to draft.
  5. 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:

  1. survey_title missing or empty.
  2. survey_title equal to the original (treated as untranslated; a title that is legitimately identical in the target language fails).
  3. Section count differs, else a section id differs at the same index.
  4. Question count differs, else at the same index: id differs, type differs, 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:

  1. 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.
  2. CF-IPCountry header when present and not XX or T1: upper-cased, source: "cloudflare", region null.
  3. ipinfo.io when IPINFO_TOKEN is set, 4 second timeout: source: "ipinfo".
  4. Otherwise {country_code: null, region: null, source: "none"}. Tests: tests/test_geo_detect.py (5). TypeScript: the maxmind npm 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

  1. Serve draft content translations to respondents (parity) or approved only (TR-8).
  2. Non-integer ratings: accept (parity) or refuse (QT-2 rule 4).
  3. Group results for unscored or null max questions (GR-3): parity, or exclude them from the maximum, which changes stored Maslow-style percentages for surveys built in the UI. If changed, stored group_results are kept as they were and only new responses use the new rule.
  4. Whether the stored config keeps null versus 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.