API Route Map
Every route the Python backend serves today, what it becomes in the TypeScript app, and what an agent needs from it. Read from the code on 5 November 2026: sondely/backend/app/main.py and every backend/app/api/*/routes.py. The design side follows TourCockpit's framework as it stands at commit f952d0eea (src/lib/api/openapi.ts, src/lib/api/errors.ts, src/lib/api/auth.ts, src/lib/assistant/refusal-legibility.ts). Sign-in itself is on Sign-in, Sessions and API Keys; the tables behind the routes are on Data Model.
Count
| Router |
File |
Mounted at |
Routes |
| auth |
backend/app/api/auth/routes.py |
/api/v1/auth |
17 |
| geo |
backend/app/api/geo/routes.py |
/api/v1/geo |
1 |
| languages |
backend/app/api/languages/routes.py |
/api/v1/languages |
1 |
| users |
backend/app/api/users/routes.py |
/api/v1 |
1 |
| surveys |
backend/app/api/surveys/routes.py |
/api/v1/surveys |
21 |
| organizations |
backend/app/api/organizations/routes.py |
/api/v1/organizations |
15 |
| sessions |
backend/app/api/sessions/routes.py |
/api/v1 |
9 |
| dashboard |
backend/app/api/dashboard/routes.py |
/api/v1/dashboard |
1 |
| invitations |
backend/app/api/invitations/routes.py |
/api/v1/invitations |
2 |
| assignments |
backend/app/api/assignments/routes.py |
/api/v1 |
8 |
| templates |
backend/app/api/templates/routes.py |
/api/v1/templates |
3 |
| impact |
backend/app/api/impact/routes.py |
/api/v1/surveys |
3 |
| notifications |
backend/app/api/notifications/routes.py |
/api/v1/notifications |
5 |
| api_keys |
backend/app/api/api_keys/routes.py |
/api/v1/organizations |
3 |
| app level |
backend/app/main.py |
/ |
5 |
| Total |
|
|
95 |
95 route decorators. Two of the app-level ones are exclusive (the SPA catch-all when a built frontend is present, the JSON root when not), so 94 answer at once. FastAPI also serves /api/v1/openapi.json, /docs and /redoc, which are generated, not written.
How to read the tables
Each router has two tables keyed by a route number: access (who may call it today and in the new design) and contract (request, response, writes, dry run, idempotency, errors).
Who may call it today names the FastAPI dependency and the role check:
| Today |
Meaning |
| none |
No dependency. Anyone on the internet |
| optional |
get_current_user: a bearer is read if present, never required |
| signed in |
get_current_active_user: any valid API key, JWT or Redis session token (backend/app/utils/auth.py) |
| member of org |
get_org_member_or_deny (backend/app/utils/permissions.py): any role, respondents included |
| OWNER/ADMIN, OWNER |
require_org_role with that list |
| creator or ... |
get_survey_with_org_access: the survey's creator passes without any role check, even if no longer a member; everyone else goes through the role check |
Three things hold for every signed-in route today and are not repeated per row: a missing or bad credential is 401 "Not authenticated"; an API key over 60 requests a minute is 429 "Rate limit exceeded" (backend/app/middleware/rate_limit.py, in memory, per replica); an API key acts as the user who created it, and the organization on the key is never checked.
Registry Rbac is the value the route gets in Sondely's copy of RouteDef.rbac. TourCockpit's type is "any" | "office" | "manager" | "owner" | "operator"; Sondely's becomes:
| Rbac |
Guard (new, in src/lib/api/auth.ts) |
Who passes |
public |
none |
Anyone. New value: TourCockpit has no public v1 routes, Sondely's respondent flow needs them |
self |
withApiSelf |
Any signed-in person, acting on their own records only. Session only, never an API key |
grant |
withApiGrant |
A respondent grant for this survey, or a signed-in person entitled to this survey |
member |
withApiMember |
Any member of the organization that owns the resource, respondent included |
admin |
withApiAdmin |
Owner or admin of that organization |
owner |
withApiOwner |
Owner only |
test |
withTestOnly |
Only when ENVIRONMENT=test; 404 otherwise, the way TourCockpit's api/auth/test-login answers 404 without E2E_AUTH_SECRET |
An API key carries one organization and one scope: read (GET only), draft (create and edit drafts, never publish, delete or send), full. The Rbac cell adds the lowest key scope that may call the route, or "no key" where only a person may. A key on another organization's resource gets 404 NOT_FOUND, never 403, as TourCockpit does for cross-tenant ids.
TS path is "same" unless a reason is given: the bearer contract is kept at cutover so the Vite frontend does not change (Sign-in, Sessions and API Keys). The Next route folder was planned as server/src/app/api/v1/<path>/route.ts with one dynamic segment name per level ([surveyId], [orgId]), so the sessions and assignments routes that share /surveys/{survey_id} and /organizations/{org_id} prefixes live in the same folders as the survey and organization routes. As built on 7 November 2026 in CatalyzeUp/sondely-platform, the routes are registered in one route registry and served through src/lib/api/dispatch.ts from route files under src/app/api/v1/; the new app serves its own screens with a session cookie, while the bearer contract stays for the Vite app until the cutover.
The plan's folder rule: with one dynamic segment name per level ([surveyId], [orgId]), so the sessions and assignments routes that share /surveys/{survey_id} and /organizations/{org_id} prefixes live in the same folders as the survey and organization routes.
Dry run: ?dryRun=true runs the whole write in its transaction and rolls back (TourCockpit src/lib/api/request-transaction.ts). "yes, delivery" means the route sends something that cannot be taken back (an email, a model call) and answers its own dry run with what it would send, as TourCockpit's src/lib/api/delivery.ts does. Sign-in routes do not take dry run.
Idempotency key: Idempotency-Key header (TourCockpit src/lib/api/idempotency.ts). "yes" means a retry without it would do the thing twice. "optional" means the write is naturally idempotent but accepts a key. "no, rate limit" means a send that a person triggers and that is throttled instead.
Errors: today's status and detail text as written in the handler, then the code it becomes. The new envelope is {error: {code, message, details}}; the codes and their classes are in Error codes for Sondely.
auth
File: backend/app/api/auth/routes.py.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| A1 |
POST |
/api/v1/auth/register |
same |
register |
none |
public, no key |
| A2 |
POST |
/api/v1/auth/login |
same |
login |
none |
public, no key |
| A3 |
GET |
/api/v1/auth/verify-email |
same |
verify_email |
none |
public, no key |
| A4 |
POST |
/api/v1/auth/resend-verification |
same |
resend_verification |
signed in |
self |
| A5 |
POST |
/api/v1/auth/forgot-password |
same |
forgot_password |
none |
public, no key |
| A6 |
POST |
/api/v1/auth/reset-password |
same |
reset_password |
none |
public, no key |
| A7 |
POST |
/api/v1/auth/request-otp |
same |
request_otp |
none |
public, no key |
| A8 |
POST |
/api/v1/auth/verify-otp |
same |
verify_otp |
none |
public, no key |
| A9 |
GET |
/api/v1/auth/google |
same |
google_login |
none |
public, no key |
| A10 |
GET |
/api/v1/auth/google/callback |
same (Google's registered redirect URI) |
google_callback |
none |
public, no key |
| A11 |
GET |
/api/v1/auth/apple |
same |
apple_login |
none |
public, no key |
| A12 |
POST |
/api/v1/auth/apple/callback |
same (Apple's registered redirect URI) |
apple_callback |
none |
public, no key |
| A13 |
POST |
/api/v1/auth/auto-enroll |
same path, new meaning: mints a respondent grant |
auto_enroll |
none: returns a full token for any existing account |
public, no key |
| A14 |
POST |
/api/v1/auth/upgrade |
same |
upgrade_account |
none: sets a password from an email alone |
grant (the grant names the user), no key |
| A15 |
POST |
/api/v1/auth/logout |
same |
logout |
signed in |
self |
| A16 |
GET |
/api/v1/auth/me |
same |
get_current_user_info |
signed in |
self |
| A17 |
GET |
/api/v1/auth/session |
same |
check_session |
signed in |
self |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| A1 |
UserCreate: email, password (min 6), full_name, locale |
AuthTokenResponse: message, user, access_token, session_token, token_type |
yes: user, verification token, tenant membership by Host, session |
no |
no: the unique email makes a retry a 409 |
400 "Email already registered" to EMAIL_TAKEN; 422 Pydantic to VALIDATION_FAILED; new PASSWORD_TOO_WEAK, RATE_LIMITED |
| A2 |
UserLogin: email, password |
AuthTokenResponse |
yes: session |
no |
no |
401 "Invalid email or password" to INVALID_CREDENTIALS; new RATE_LIMITED |
| A3 |
query token |
307 to FRONTEND_URL/auth/signin?verified=true |
yes, on a GET |
no |
no |
400 "Invalid or expired verification token" to TOKEN_INVALID; 400 "Verification token expired" to TOKEN_EXPIRED |
| A4 |
none |
MessageResponse |
yes: token, email |
no |
no, rate limit |
401; new RATE_LIMITED |
| A5 |
{email} |
MessageResponse, same text whether the email exists or not |
yes: reset token, email |
no |
no, rate limit |
none today; new RATE_LIMITED |
| A6 |
{token, password} |
MessageResponse |
yes: password, token used |
no |
no |
400 "Password must be at least 6 characters" to PASSWORD_TOO_WEAK; 400 "Invalid or expired reset token" to TOKEN_INVALID; 400 "Reset token expired" to TOKEN_EXPIRED |
| A7 |
{email} |
MessageResponse |
yes: code, email |
no |
no, rate limit |
429 "Too many codes requested. Please try again later." to RATE_LIMITED |
| A8 |
{email, code, full_name?} |
AuthTokenResponse |
yes: user created or promoted, membership by Host, session |
no |
no |
400 "Invalid or expired code", "Code expired" to OTP_EXPIRED; 400 "Invalid code" to OTP_INVALID |
| A9 |
query return_to |
307 to Google |
yes: OAuth state |
no |
no |
500 "Google OAuth not configured" to NOT_CONFIGURED |
| A10 |
query code, state |
307 to {origin}/auth/google/callback?access_token=...&session_token=... |
yes: user, link, membership, session |
no |
no |
400 "Invalid or expired OAuth state" to OAUTH_STATE_INVALID; 400 "Failed to exchange authorization code", "Failed to get user info from Google" to OAUTH_PROVIDER_FAILED |
| A11 |
query return_to |
307 to Apple |
yes: OAuth state |
no |
no |
500 "Apple Sign In not configured" to NOT_CONFIGURED |
| A12 |
form code, state, user |
303 to {origin}/auth/apple/callback?access_token=...&session_token=... |
yes |
no |
no |
400 "Missing Apple authorization code" to VALIDATION_FAILED; 400 state to OAUTH_STATE_INVALID; 400 "Failed to exchange Apple authorization code", "Apple did not return an id_token" to OAUTH_PROVIDER_FAILED; 400 "Apple did not provide an email" to OAUTH_EMAIL_MISSING |
| A13 |
{email, survey_id, organization_id}; survey_id is never read |
AutoEnrollResponse: user_id, token (4 hours, scope: respondent never checked), is_new |
yes: email-only user, RESPONDENT membership |
no |
optional: the new design returns the live grant on a retry |
400 "Organization not found" to NOT_FOUND; new SIGN_IN_REQUIRED for an existing account, SURVEY_NOT_OPEN |
| A14 |
{email, password, full_name?}; email from the body |
AuthTokenResponse |
yes: password set |
no |
no |
400 password length to PASSWORD_TOO_WEAK; 404 "User not found" (an enumeration oracle) to GRANT_OUT_OF_SCOPE; 400 "Account is already a full account" to ACCOUNT_ALREADY_FULL |
| A15 |
none |
MessageResponse |
no today (nothing is revoked); yes after: the session row is deleted |
no |
optional |
401 |
| A16 |
none |
UserMeResponse, including roles and is_admin |
no |
no |
no |
401 |
| A17 |
none |
SessionCheckResponse: valid, user_id, email |
no |
no |
no |
401 |
geo, languages, users
Files: backend/app/api/geo/routes.py, backend/app/api/languages/routes.py, backend/app/api/users/routes.py.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| G1 |
GET |
/api/v1/geo/detect |
same |
detect_geo |
none |
public |
| L1 |
GET |
/api/v1/languages |
same |
list_languages |
none |
public |
| U1 |
PATCH |
/api/v1/users/me |
same |
update_me |
signed in |
self |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| G1 |
none; reads x-forwarded-for, cf-ipcountry |
raw dict: country_code, region, source |
no |
no |
no |
none; every lookup failure answers source: none |
| L1 |
none |
raw dict from services/languages.registry() |
no |
no |
no |
none |
| U1 |
UserLocaleUpdate: locale |
raw dict: locale |
yes |
yes |
optional |
422 "Locale 'x' is not an enabled language" to LOCALE_NOT_ENABLED |
surveys
File: backend/app/api/surveys/routes.py.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| S1 |
POST |
/api/v1/surveys |
same |
create_survey |
OWNER/ADMIN of organization_id in the body |
admin, key draft |
| S2 |
GET |
/api/v1/surveys |
same |
get_surveys |
signed in; returns surveys the caller created plus all surveys of orgs where they are OWNER/ADMIN |
self across the caller's orgs; a key reads its own org, key read |
| S3 |
GET |
/api/v1/surveys/{survey_id} |
same |
get_survey |
creator, or member of org (respondents see drafts too) |
member, key read |
| S4 |
GET |
/api/v1/surveys/public/{public_id} |
same |
get_public_survey |
none |
public |
| S5 |
GET |
/api/v1/surveys/{survey_id}/preview |
same |
preview_survey |
creator, or OWNER/ADMIN |
admin, key read |
| S6 |
PUT |
/api/v1/surveys/{survey_id} |
same; publishing runs the validator |
update_survey |
creator, or OWNER/ADMIN |
admin, key draft; setting status: PUBLISHED needs key full |
| S7 |
DELETE |
/api/v1/surveys/{survey_id} |
same, becomes a soft delete |
delete_survey |
creator, or OWNER/ADMIN |
admin, key full |
| S8 |
POST |
/api/v1/surveys/{survey_id}/questions |
same |
create_question |
creator, or OWNER/ADMIN |
admin, key draft |
| S9 |
DELETE |
/api/v1/surveys/{survey_id}/questions/{question_id} |
same |
delete_question |
creator, or OWNER/ADMIN |
admin, key draft |
| S10 |
POST |
/api/v1/surveys/{survey_id}/respond |
same; accepts the id or the public id, as today |
submit_survey_response |
optional |
public or grant |
| S11 |
POST |
/api/v1/surveys/responses/{response_id}/claim |
same |
claim_response |
signed in; any unclaimed response, by id |
self, plus the claim token returned at submit |
| S12 |
GET |
/api/v1/surveys/{survey_id}/my-responses |
same |
get_my_survey_responses |
signed in; matched by user id or by email |
self or grant |
| S13 |
DELETE |
/api/v1/surveys/{survey_id}/my-responses/{response_id} |
same |
delete_my_survey_response |
signed in; owner by user id or email |
self |
| S14 |
GET |
/api/v1/surveys/{survey_id}/responses |
same |
get_survey_responses |
creator, or member (RESPONDENT sees own) |
member, key read |
| S15 |
GET |
/api/v1/surveys/{survey_id}/responses/export.csv |
same |
export_survey_responses_csv |
creator, or OWNER/ADMIN |
admin, key read |
| S16 |
DELETE |
/api/v1/surveys/{survey_id}/responses |
same, becomes a soft delete |
delete_all_responses |
creator (any role, through the creator rule), or OWNER |
owner, key full |
| S17 |
GET |
/api/v1/surveys/{survey_id}/compliance |
same |
get_survey_compliance |
creator, or OWNER/ADMIN |
admin, key read |
| S18 |
GET |
/api/v1/surveys/{survey_id}/respondent/{email}/history |
same |
get_respondent_history |
creator, or OWNER/ADMIN |
admin, key read |
| S19 |
POST |
/api/v1/surveys/{survey_id}/translate |
same |
translate_survey_endpoint |
creator, or OWNER/ADMIN |
admin, key draft |
| S20 |
PATCH |
/api/v1/surveys/{survey_id}/translations/{lang} |
same |
edit_translation |
creator, or OWNER/ADMIN |
admin, key draft; status: approved needs key full |
| S21 |
DELETE |
/api/v1/surveys/{survey_id}/translations/{lang} |
same |
delete_translation |
creator, or OWNER/ADMIN |
admin, key draft |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| S1 |
SurveyCreate: organization_id, title, description, settings, questions[], recurrence, reminder_intervals, require_email, result_type, question_groups, template_id |
Survey with questions |
yes |
yes |
yes: a retried create makes a second survey |
400 "Organization not found" to NOT_FOUND; 403 role to FORBIDDEN (or NOT_FOUND when not a member); 500 "Failed to create survey"; new INVALID with reasons |
| S2 |
query organization_id, skip, limit (max 1000) |
List[Survey], each with its questions |
no |
no |
no |
500 "Failed to fetch surveys" |
| S3 |
none |
Survey |
no |
no |
no |
404 "Survey not found"; 403 "Not a member of this organization" to NOT_FOUND; 500 "Failed to fetch survey" |
| S4 |
query lang |
Survey with translated text and the org's profile_config |
no |
no |
no |
404 "Survey not found or not published" to SURVEY_NOT_OPEN |
| S5 |
none |
Survey, any status |
no |
no |
no |
404; 403 to FORBIDDEN or NOT_FOUND; 500 "Failed to fetch survey preview" |
| S6 |
SurveyUpdate: any survey field, including status and is_active |
Survey |
yes |
yes |
optional |
404; 403; 500 "Failed to update survey"; new INVALID on publish, SURVEY_HAS_RESPONSES when groups or scoring change under responses |
| S7 |
none |
raw dict: message |
yes: survey, questions and every response, hard delete |
yes |
optional |
404; 403; 500 "Failed to delete survey"; new SURVEY_DELETED on a second call |
| S8 |
QuestionCreate: text, type, required, order, options, scoring_config, group_id, weight, rating_labels |
Question |
yes |
yes |
yes |
409 "Cannot modify questions: survey has N responses. Delete all responses first." to SURVEY_HAS_RESPONSES; new INVALID |
| S9 |
none |
raw dict: message |
yes |
yes |
optional |
409 to SURVEY_HAS_RESPONSES; 404 "Question not found" to NOT_FOUND |
| S10 |
SurveyResponseCreate: respondent_email (free text), responses (unchecked dictionary), score (used when nothing scores), geo, respondent_name, context |
SurveyResponse with score, breakdown, group results |
yes: response, assignment status |
yes: returns the score it would store |
yes: a retry submits twice |
404 "Survey not found or not accepting responses" to SURVEY_NOT_OPEN; 422 "Email is required to take this survey", "Location is required...", "Name is required..." to PROFILE_FIELD_REQUIRED; 409 "You can retake this survey after YYYY-MM-DD" to COOLDOWN_ACTIVE; new ANSWER_INVALID |
| S11 |
none |
SurveyResponse |
yes: user id and email overwritten |
yes |
optional |
404 "Response not found"; 409 "Response already belongs to an account" to RESPONSE_ALREADY_CLAIMED |
| S12 |
query limit (max 200) |
List[SurveyResponse] |
no |
no |
no |
404 "Survey not found" |
| S13 |
none |
raw dict: message, id |
yes: hard delete |
yes |
optional |
404 "Survey not found", "Response not found"; 403 "Not your response" to NOT_FOUND |
| S14 |
query skip, limit |
List[SurveyResponse] |
no |
no |
no |
404; 403; 500 "Failed to fetch responses" |
| S15 |
query lang |
text/csv attachment |
no |
no |
no |
404; 403 |
| S16 |
none |
raw dict: message, deleted_count |
yes: every response, hard delete |
yes: returns the count |
optional |
404; 403; 500 "Failed to delete responses" |
| S17 |
none |
List[ComplianceMember] |
no |
no |
no |
404; 403; 500 "Failed to fetch compliance data" |
| S18 |
none; email in the path |
List[RespondentHistoryEntry] |
no |
no |
no |
404; 403; 500 "Failed to fetch respondent history" |
| S19 |
TranslateRequest: target_languages, context |
raw dict: available_languages, default_language, languages (per-language status, failures inside a 200) |
yes: translations, plus a model call |
yes, delivery: languages and question count, no model call |
yes: a retry pays the model twice |
400 "Unsupported language: x" to LANGUAGE_UNSUPPORTED; 400 "Survey has no questions to translate" to NOTHING_TO_TRANSLATE; 500 "Translation failed: {exception text}" to TRANSLATION_FAILED |
| S20 |
TranslationEditRequest: survey (free dict), questions (free dict by question id, never checked to belong to this survey), status |
raw dict: message, language, status |
yes |
yes |
optional |
404 "No translation found for language: x" to NOT_FOUND; 500 "Failed to update translation"; new INVALID |
| S21 |
none |
raw dict: message, language |
yes |
yes |
optional |
400 "Cannot remove the default language" to LANGUAGE_IS_DEFAULT; 404 to NOT_FOUND |
organizations
File: backend/app/api/organizations/routes.py.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| O1 |
POST |
/api/v1/organizations |
same |
create_organization |
signed in, any user |
self, no key |
| O2 |
GET |
/api/v1/organizations |
same |
get_my_organizations |
signed in |
self; a key reads its own org, key read |
| O3 |
GET |
/api/v1/organizations/by-host |
same; the host is resolved in Next middleware too |
get_organization_by_host |
none |
public |
| O4 |
GET |
/api/v1/organizations/{org_id} |
same |
get_organization |
member of org |
member, key read |
| O5 |
PUT |
/api/v1/organizations/{org_id} |
same |
update_organization |
OWNER/ADMIN |
admin, key full |
| O6 |
PUT |
/api/v1/organizations/{org_id}/custom-domain |
same |
set_organization_custom_domain |
OWNER |
owner, no key |
| O7 |
PUT |
/api/v1/organizations/{org_id}/profile-config |
same |
set_organization_profile_config |
OWNER/ADMIN |
admin, key full |
| O8 |
DELETE |
/api/v1/organizations/{org_id} |
same, becomes a soft delete |
delete_organization |
OWNER |
owner, no key |
| O9 |
GET |
/api/v1/organizations/{org_id}/members |
same |
get_organization_members |
member of org: respondents read every member's email |
admin, key read; a respondent gets FORBIDDEN |
| O10 |
GET |
/api/v1/organizations/{org_id}/members/export.csv |
same |
export_organization_members_csv |
OWNER/ADMIN |
admin, key read |
| O11 |
POST |
/api/v1/organizations/{org_id}/members/invite |
same path; email and role move from the query string to a JSON body (the contract suite pins both forms until the screen moves) |
invite_member |
OWNER/ADMIN |
admin, key full |
| O12 |
DELETE |
/api/v1/organizations/{org_id}/members/{member_id} |
same |
remove_member |
OWNER/ADMIN; an admin may remove another admin |
admin for respondents, owner for admins, no key |
| O13 |
PUT |
/api/v1/organizations/{org_id}/members/{member_id}/role |
same; role moves to the body like O11 |
update_member_role |
OWNER |
owner, no key |
| O14 |
GET |
/api/v1/organizations/{org_id}/invitations |
same |
get_organization_invitations |
member of org: respondents read pending invitation emails |
admin, key read |
| O15 |
DELETE |
/api/v1/organizations/{org_id}/invitations/{invitation_id} |
same |
cancel_invitation |
OWNER/ADMIN |
admin, key full |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| O1 |
OrganizationCreate: name, description, settings; website, industry, size and custom_domain are accepted and silently dropped |
Organization |
yes: org, OWNER membership |
yes |
yes |
400 "Organization name already exists" to ORG_NAME_TAKEN; dropped fields become UNKNOWN_FIELD or are stored |
| O2 |
query skip, limit |
List[Organization] |
no |
no |
no |
none |
| O3 |
Host header |
OrganizationByHost: id, name, description, custom_domain |
no |
no |
no |
404 "No tenant for host" to NOT_FOUND (the frontend reads it as "no tenant") |
| O4 |
none |
Organization |
no |
no |
no |
403 "Not a member of this organization" to NOT_FOUND; 404 "Organization not found" |
| O5 |
OrganizationUpdate: name, description, website, industry, size, settings |
Organization |
yes |
yes |
optional |
403 to FORBIDDEN; 404 "Organization not found" |
| O6 |
{custom_domain} or null |
Organization |
yes |
yes |
optional |
400 invalid domain text to DOMAIN_INVALID; 409 "Custom domain is already claimed by another organization" to DOMAIN_TAKEN; 404 |
| O7 |
ProfileConfigUpdate: profile_config by field, enabled, required |
Organization |
yes |
yes |
optional |
400 "Unsupported profile field: x" to PROFILE_FIELD_UNSUPPORTED; 404 |
| O8 |
none |
204 |
yes: org, members, invitations, hard delete; surveys, responses, assignments and keys are left behind (inferred: no delete on them) |
yes |
optional |
403 to FORBIDDEN; new ORG_HAS_SURVEYS |
| O9 |
none |
List[OrgMemberResponse]: id, user {id, email, full_name}, role, joined_at |
no |
no |
no |
403 to NOT_FOUND |
| O10 |
query lang |
text/csv attachment |
no |
no |
no |
404 "Organization not found" |
| O11 |
query email, role (default RESPONDENT) |
InviteMemberResponse: message, type (direct, auto_accepted, invitation), token |
yes: membership or invitation, invitation email; in development it creates the user with the password testing123 |
yes, delivery: who would be added or emailed |
yes: a retry sends a second email |
400 "Role must be RESPONDENT or ADMIN" to ROLE_NOT_ALLOWED; 400 "User is already a member" to ALREADY_MEMBER |
| O12 |
none |
204 |
yes |
yes |
optional |
404 "Member not found"; 400 "Cannot remove organization owner" to OWNER_PROTECTED |
| O13 |
query role |
UpdateRoleResponse: message, new_role |
yes |
yes |
optional |
400 role to ROLE_NOT_ALLOWED; 404 "Member not found", also answered when the role is unchanged (modified_count == 0); an OWNER can be demoted, leaving no owner, to new LAST_OWNER |
| O14 |
none |
List[OrgInvitationResponse]; the model requires organization_id, which the handler never returns, so any non-empty list fails response validation as a 500 (inferred from code, not run) |
no |
no |
no |
403 to NOT_FOUND |
| O15 |
none |
204 |
yes |
yes |
optional |
404 "Invitation not found" |
sessions
File: backend/app/api/sessions/routes.py. None of these routes checks that the survey is published or that the caller belongs to its organization: any signed-in token starts a session on any survey id, drafts included.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| SE1 |
POST |
/api/v1/surveys/{survey_id}/sessions/start |
same |
start_survey_session |
signed in |
grant |
| SE2 |
GET |
/api/v1/sessions/{session_id} |
same |
get_session |
signed in, own session |
grant (session of that survey) or self |
| SE3 |
PUT |
/api/v1/sessions/{session_id}/save |
same |
save_session_progress |
signed in, own session |
grant or self |
| SE4 |
POST |
/api/v1/sessions/{session_id}/complete |
same |
complete_session |
signed in, own session |
grant or self |
| SE5 |
POST |
/api/v1/sessions/{session_id}/abandon |
same |
abandon_session |
signed in, own session |
grant or self |
| SE6 |
GET |
/api/v1/surveys/{survey_id}/sessions |
same |
get_survey_sessions |
signed in, own sessions |
grant or self |
| SE7 |
GET |
/api/v1/users/me/sessions |
same |
get_my_sessions |
signed in |
self |
| SE8 |
GET |
/api/v1/surveys/{survey_id}/sessions/stats |
same |
get_session_stats |
signed in, own sessions |
grant or self |
| SE9 |
GET |
/api/v1/surveys/{survey_id}/can-attempt |
same |
check_can_attempt |
signed in |
grant or self |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| SE1 |
none |
SurveySession; an in-progress session is returned instead of a new one |
yes |
yes |
optional: already returns the open session |
404 "Survey not found"; 400 "You can retake this survey after YYYY-MM-DD" to COOLDOWN_ACTIVE; new SURVEY_NOT_OPEN |
| SE2 |
none |
SurveySession |
no |
no |
no |
404 "Session not found" |
| SE3 |
SurveySessionUpdate: responses, progress_percentage, time_spent_seconds, current_question_index, status |
SurveySession |
yes |
yes |
optional |
404; 400 "Cannot save to a completed or abandoned session" to SESSION_CLOSED; the progress branch does await db.questions.find(...), which the SafeCursor in backend/app/core/database.py cannot await, so a save with answers is a 500 (inferred from code, not run); new ANSWER_INVALID |
| SE4 |
free JSON dictionary of answers |
SurveySession |
yes: session, plus a survey_responses row with _id only, no id, no score |
yes |
yes: a retry would write a second response once the closed check is per request |
404; 400 "Session is already completed or abandoned" to SESSION_CLOSED |
| SE5 |
none |
SurveySession |
yes |
yes |
optional |
404; 400 to SESSION_CLOSED |
| SE6 |
none |
List[SurveySession] |
no |
no |
no |
awaits a cursor, a 500 (inferred) |
| SE7 |
query status_filter |
List[SurveySession] |
no |
no |
no |
awaits a cursor, a 500 (inferred) |
| SE8 |
none |
SessionStats |
no |
no |
no |
awaits a cursor, a 500 (inferred) |
| SE9 |
none |
raw dict: can_attempt, attempts_used, min_days_between_attempts, next_eligible_date, has_active_session, active_session_id, reason |
no |
no |
no |
404 "Survey not found" |
dashboard, invitations
Files: backend/app/api/dashboard/routes.py, backend/app/api/invitations/routes.py.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| D1 |
GET |
/api/v1/dashboard/stats |
same |
get_dashboard_stats |
signed in |
self |
| I1 |
GET |
/api/v1/invitations/accept |
same |
validate_invitation |
none; the token is the credential |
public |
| I2 |
POST |
/api/v1/invitations/accept |
same |
accept_invitation |
signed in, email must match the invitation |
self, no key |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| D1 |
none |
raw dict, one of two shapes: admin (stats, recent_surveys, organizations) when the caller is OWNER/ADMIN anywhere or has no membership at all, respondent (is_respondent, my_surveys, organizations) otherwise. AdminDashboardResponse and RespondentDashboardResponse are imported but not used as the response model |
no |
no |
no |
401 |
| I1 |
query token |
InvitationPreviewResponse: status, invitation {email, role, org_name, org_id} |
no |
no |
no |
404 "Invitation not found" to NOT_FOUND; 400 "Invitation has already been accepted" to INVITATION_USED; 400 "Invitation has expired" to INVITATION_EXPIRED |
| I2 |
query token |
InvitationAcceptResponse: message, org_id |
yes: membership, invitation accepted |
yes |
optional: a second accept answers "already a member" |
the I1 errors; 403 "This invitation was sent to a different email address" to INVITATION_EMAIL_MISMATCH |
assignments
File: backend/app/api/assignments/routes.py.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| AS1 |
POST |
/api/v1/organizations/{org_id}/surveys/{survey_id}/assign |
same |
assign_members |
OWNER/ADMIN |
admin, key full |
| AS2 |
DELETE |
/api/v1/organizations/{org_id}/surveys/{survey_id}/assign/{member_id} |
same |
unassign_member |
OWNER/ADMIN |
admin, key full |
| AS3 |
GET |
/api/v1/organizations/{org_id}/surveys/{survey_id}/assignments |
same |
get_assignments |
member of org (RESPONDENT sees own) |
member, key read |
| AS4 |
GET |
/api/v1/me/assignments |
same |
get_my_assignments |
signed in |
self |
| AS5 |
GET |
/api/v1/organizations/{org_id}/overdue |
same |
get_overdue_assignments |
OWNER/ADMIN |
admin, key read |
| AS6 |
POST |
/api/v1/organizations/{org_id}/surveys/{survey_id}/assign/{member_id}/extend |
same |
extend_due_date |
OWNER/ADMIN |
admin, key full |
| AS7 |
POST |
/api/v1/organizations/{org_id}/surveys/{survey_id}/assign/{member_id}/waive |
same |
waive_assignment |
OWNER/ADMIN |
admin, key full |
| AS8 |
POST |
/api/v1/organizations/{org_id}/surveys/{survey_id}/assign/{member_id}/remind |
same |
send_manual_reminder |
OWNER/ADMIN |
admin, key full |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| AS1 |
SurveyAssignmentCreate: member_ids (max 500) |
AssignResult: message, assigned_count, skipped_count, assignments |
yes |
yes |
optional: duplicates are skipped |
404 "Survey not found in this organization" to NOT_FOUND; non-members and duplicates are skipped silently, which becomes a per-id reason in details |
| AS2 |
none |
204 |
yes |
yes |
optional |
404 "Assignment not found" |
| AS3 |
none |
List[SurveyAssignmentWithMember] |
no |
no |
no |
403 to NOT_FOUND |
| AS4 |
none |
List[MyAssignment] |
no |
no |
no |
none |
| AS5 |
none |
List[OverdueAssignment]; the model requires due_date, member_email and member_name as strings and the handler passes the raw row without due_date, so any overdue row is a 500 (inferred from code, not run) |
no |
no |
no |
403 |
| AS6 |
ExtendRequest: days 1 to 365 |
ExtendResponse: message, new_due_date |
yes |
yes |
yes: each call adds the days again |
404 "Assignment not found" |
| AS7 |
none |
WaiveResponse: message, status |
yes |
yes |
yes: on a recurring survey each call pushes the next due date again |
404 "Assignment not found" |
| AS8 |
none |
MessageResponse |
yes: email, reminder count |
yes, delivery |
yes: a retry sends a second email |
404 "Assignment not found", "User or survey not found"; new NOT_DELIVERED |
templates, impact
Files: backend/app/api/templates/routes.py, backend/app/api/impact/routes.py (mounted under /api/v1/surveys).
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| T1 |
GET |
/api/v1/templates |
same |
list_templates |
none |
public |
| T2 |
GET |
/api/v1/templates/{template_id} |
same |
get_template |
none |
public |
| T3 |
POST |
/api/v1/templates/{template_id}/create-survey |
same |
create_survey_from_template |
OWNER/ADMIN of organization_id in the body |
admin, key draft |
| IM1 |
GET |
/api/v1/surveys/{survey_id}/impact |
same |
get_survey_impact |
OWNER/ADMIN of the survey's org (no creator rule here) |
admin, key read |
| IM2 |
GET |
/api/v1/surveys/{survey_id}/export.csv |
same; the name sits next to /responses/export.csv and means the impact report |
export_impact_csv |
OWNER/ADMIN |
admin, key read |
| IM3 |
GET |
/api/v1/surveys/{survey_id}/report.pdf |
same |
export_impact_pdf |
OWNER/ADMIN |
admin, key read |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| T1 |
none |
list[SurveyTemplateResponse] |
no |
no |
no |
none |
| T2 |
none |
SurveyTemplateResponse |
no |
no |
no |
404 "Template not found" to NOT_FOUND |
| T3 |
CreateFromTemplate: organization_id, title, description |
raw dict: the survey document plus its questions, no response model |
yes |
yes |
yes |
404 "Template not found"; 400 "Organization not found" to NOT_FOUND; 403 |
| IM1 |
none |
ImpactReport |
no |
no |
no |
404 "Survey not found"; 403; a survey without organization_id raises a KeyError, a 500 (inferred) |
| IM2 |
query lang |
text/csv attachment |
no |
no |
no |
as IM1 |
| IM3 |
query lang |
application/pdf attachment |
no |
no |
no |
as IM1 |
notifications
File: backend/app/api/notifications/routes.py.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| N1 |
GET |
/api/v1/notifications/preferences |
same |
get_preferences |
signed in |
self |
| N2 |
PUT |
/api/v1/notifications/preferences |
same |
update_preferences |
signed in |
self |
| N3 |
POST |
/api/v1/notifications/preferences/mute-survey/{survey_id} |
same |
mute_survey |
signed in |
self |
| N4 |
POST |
/api/v1/notifications/preferences/unmute-survey/{survey_id} |
same |
unmute_survey |
signed in |
self |
| N5 |
GET |
/api/v1/notifications/unsubscribe |
same GET shows the confirmation; a new POST on the same path does the unsubscribe (one-click, RFC 8058), so a mail scanner that follows the link changes nothing |
unsubscribe |
none; the token is the credential |
public |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| N1 |
none |
raw stored document, including unsubscribe_token and user_id |
yes, on a GET: defaults are created on first read |
no |
no |
none |
| N2 |
NotificationPreferences (a local class in the routes file, full replace including muted_survey_ids) |
raw stored document |
yes |
yes |
optional |
422 Pydantic to VALIDATION_FAILED |
| N3 |
none; the survey id is never checked |
MessageResponse |
yes |
yes |
optional |
none; new NOT_FOUND for an unknown survey |
| N4 |
none |
MessageResponse |
yes |
yes |
optional |
none |
| N5 |
query token |
MessageResponse |
yes, on a GET |
no |
optional |
404 "Invalid unsubscribe token" to TOKEN_INVALID |
api_keys
File: backend/app/api/api_keys/routes.py (mounted under /api/v1/organizations). Keys are managed only by a signed-in person, never by another key.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| K1 |
POST |
/api/v1/organizations/{org_id}/api-keys |
same |
create_api_key |
OWNER/ADMIN; also any API key whose creator is OWNER/ADMIN |
admin, no key |
| K2 |
GET |
/api/v1/organizations/{org_id}/api-keys |
same |
list_api_keys |
OWNER/ADMIN |
admin, no key |
| K3 |
DELETE |
/api/v1/organizations/{org_id}/api-keys/{key_id} |
same |
revoke_api_key |
OWNER/ADMIN |
admin, no key |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| K1 |
ApiKeyCreate: name (1 to 100); new: scope, expiresAt |
ApiKeyCreatedResponse: id, name, key_hint, created_at, last_used_at, request_count, is_revoked, full_key (once) |
yes |
yes: never mints a key |
yes: a retry would mint a second live key |
403 |
| K2 |
none |
List[ApiKeyResponse] |
no |
no |
no |
403 |
| K3 |
none |
204 |
yes: revoked_at |
yes |
optional |
404 "API key not found"; 400 "Key already revoked" to KEY_ALREADY_REVOKED |
app level
File: backend/app/main.py.
| # |
Method |
Python path |
TS path |
Handler |
Today |
Registry Rbac |
| M1 |
GET |
/health |
same path kept for the container probe (TourCockpit's is /api/health); probe config inferred, check in catalyzeup-devops |
health_check |
none |
outside the registry |
| M2 |
GET |
/api/v1/dev/emails |
/api/v1/test/emails |
list_dev_emails |
none; registered only when ENVIRONMENT is development or test |
test |
| M3 |
DELETE |
/api/v1/dev/emails |
/api/v1/test/emails |
delete_dev_emails |
as M2 |
test |
| M4 |
GET |
/{full_path:path} |
Next routing. As built, the new app serves every screen itself and has no Vite fallback (Screens) |
serve_frontend |
none; only when backend/static exists |
outside the registry |
| M5 |
GET |
/ |
Next landing page |
root |
none; only when backend/static is absent |
outside the registry |
| # |
Request |
Response |
Writes |
Dry run |
Idempotency |
Errors today, then code |
| M1 |
none |
raw dict: status, version, environment |
no |
no |
no |
none |
| M2 |
none |
list of intercepted emails from services/email.get_dev_emails() |
no |
no |
no |
none |
| M3 |
none |
raw dict: message |
yes |
no |
no |
none |
| M4 |
path |
a static file or index.html |
no |
no |
no |
none |
| M5 |
none |
raw dict: message, version, docs |
no |
no |
no |
none |
M2 and M3 move under one test prefix so a single guard and a single test cover every test-only route. The contract suite runs against both backends, so the Python side gets the same /api/v1/test/* paths (see Testing and Bugs).
Error codes for Sondely
TourCockpit keeps one class per code in src/lib/assistant/refusal-legibility.ts, typed so a new code does not compile until it is classed: ask (the person fixes it by saying something), explain (a fact of the data, with an offer), denied (their access does not reach it), retry (the data moved; read again and prepare again). Sondely keeps the envelope, the typing and the four classes and replaces the 65 tour codes with these. Status mapping follows TourCockpit src/lib/api/errors.ts: 403 strictly means wrong role, and anything outside the caller's organization is 404.
Framework, copied from TourCockpit
| Code |
Status |
Class |
Used for |
UNAUTHORIZED |
401 |
denied |
No credential, or a revoked or expired one |
FORBIDDEN |
403 |
denied |
Signed in, wrong role in this organization |
NOT_FOUND |
404 |
explain |
Missing, deleted from view, or in another organization. details.resource names what |
VALIDATION_FAILED |
400 |
ask |
Zod shape failure; details carries the flattened issues |
UNKNOWN_FIELD |
400 |
ask |
A field the route does not take (src/lib/api/strict-request.ts) |
INVALID |
422 |
ask |
A semantic rule; details.reason is one of the reasons below |
CONFLICT |
409 |
retry |
A unique violation the service did not name |
RATE_LIMITED |
429 |
retry |
With Retry-After |
IDEMPOTENCY_KEY_REUSED |
422 |
explain |
The key was used for a different request or caller |
NOT_DELIVERED |
422 |
explain |
An email the route had to send was refused by the mail service |
NOT_READY |
503 |
retry |
A dependency (database, mail) is not up |
TIMED_OUT |
504 |
explain, unsure |
The answer was lost; whether it happened is unknown |
BATCH_REF_UNRESOLVED, BATCH_ITEM_STANDALONE, BATCH_ITEM_NOT_A_WRITE |
400 |
ask |
POST /api/v1/batch, copied with src/lib/api/batch.ts |
Access and sign-in
| Code |
Status |
Class |
Today's cases |
INVALID_CREDENTIALS |
401 |
ask |
A2 |
EMAIL_TAKEN |
409 |
explain |
A1; offer: sign in, or sign in with a code |
PASSWORD_TOO_WEAK |
422 |
ask |
A1, A6, A14; rules from TourCockpit src/lib/auth/password-policy.ts |
TOKEN_INVALID |
400 |
explain |
A3, A6, N5; offer: request a new link |
TOKEN_EXPIRED |
422 |
explain |
A3, A6; offer: request a new link |
OTP_INVALID |
422 |
ask |
A8 |
OTP_EXPIRED |
422 |
explain |
A8; offer: send a new code |
OAUTH_STATE_INVALID |
400 |
retry |
A10, A12; again: start the sign-in again |
OAUTH_PROVIDER_FAILED |
502 |
retry |
A10, A12 |
OAUTH_EMAIL_MISSING |
422 |
explain |
A12; offer: sign in with an email code |
NOT_CONFIGURED |
503 |
explain |
A9, A11 |
ACCOUNT_ALREADY_FULL |
409 |
explain |
A14; offer: sign in |
SIGN_IN_REQUIRED |
409 |
ask |
New on A13: the email has an account, so ask for the code sent to it instead of minting a grant |
GRANT_OUT_OF_SCOPE |
403 |
denied |
New: a respondent grant used outside its survey, or on a non-respondent route |
GRANT_EXPIRED |
401 |
explain |
New; offer: open the survey link again |
KEY_SCOPE_TOO_NARROW |
403 |
denied |
New: a read key on a write, a draft key on publish, delete or send |
KEY_ALREADY_REVOKED |
409 |
explain |
K3 |
LOCALE_NOT_ENABLED |
422 |
ask |
U1 |
Organizations and members
| Code |
Status |
Class |
Today's cases |
ORG_NAME_TAKEN |
409 |
ask |
O1; ask for another name |
DOMAIN_INVALID |
422 |
ask |
O6 |
DOMAIN_TAKEN |
409 |
explain |
O6 |
PROFILE_FIELD_UNSUPPORTED |
422 |
ask |
O7; details list the supported fields |
ALREADY_MEMBER |
409 |
explain |
O11 |
ROLE_NOT_ALLOWED |
422 |
ask |
O11, O13; roles that can be given are respondent and admin |
OWNER_PROTECTED |
409 |
explain |
O12 |
LAST_OWNER |
409 |
explain |
New on O13 and O12: the change would leave no owner |
ORG_HAS_SURVEYS |
409 |
explain |
New on O8; offer: delete or move the surveys first |
INVITATION_USED |
409 |
explain |
I1, I2 |
INVITATION_EXPIRED |
422 |
explain |
I1, I2; offer: ask the organization for a new invitation |
INVITATION_EMAIL_MISMATCH |
403 |
denied |
I2 |
Surveys, questions and translations
| Code |
Status |
Class |
Today's cases |
SURVEY_NOT_OPEN |
404 |
explain |
S4, S10, A13, SE1: not published, inactive or deleted |
SURVEY_HAS_RESPONSES |
409 |
explain |
S8, S9, S6; offer: duplicate the survey, or an owner deletes the responses |
SURVEY_DELETED |
409 |
explain |
New on soft-deleted surveys; offer: restore |
LANGUAGE_UNSUPPORTED |
422 |
ask |
S19 |
LANGUAGE_IS_DEFAULT |
409 |
explain |
S21 |
NOTHING_TO_TRANSLATE |
422 |
explain |
S19; offer: add questions first |
TRANSLATION_FAILED |
502 |
retry |
S19, replacing the 500 that today returns the exception text |
INVALID reasons for the survey validator, all new (today a survey is published with no checks): NO_QUESTIONS, CHOICE_WITHOUT_OPTIONS, SCORING_TYPE_MISMATCH (scoring type does not fit the question type), OPTION_WEIGHTS_MISMATCH, GROUP_UNKNOWN (a question points to a group the survey does not have), DUPLICATE_ORDER, RECURRENCE_INTERVAL_MISSING, TRANSLATION_SHAPE (a translation that changes ids or option counts, the rule services/translation.py already enforces), QUESTION_NOT_IN_SURVEY (S20 today writes to any question id). Each is judged: body when the request alone shows it, judged: record when only the stored survey does, as in TourCockpit.
Responses, sessions and assignments
| Code |
Status |
Class |
Today's cases |
PROFILE_FIELD_REQUIRED |
422 |
ask |
S10: email, name or location; details.field names it |
ANSWER_INVALID |
422 |
ask |
New on S10, SE3, SE4: an answer that does not fit its question type or options |
COOLDOWN_ACTIVE |
409 |
explain |
S10, SE1; details.nextEligibleAt, offer that date |
RESPONSE_ALREADY_CLAIMED |
409 |
explain |
S11 |
SESSION_CLOSED |
409 |
explain |
SE3, SE4, SE5; offer: start a new attempt |
That is 15 framework codes (three of them for batch) plus 42 Sondely codes, and 9 INVALID reasons. INTERNAL (500) stays unclassed: it is a defect, logged with the trace id, never a refusal.
Frontend call sites that depend on response shapes
From frontend/src. Two clients exist side by side: axios (src/api/client.ts, src/utils/api.ts) and plain fetch with API_ENDPOINTS (src/config/api.ts). Both send Authorization: Bearer <access_token from localStorage>. The TypeScript backend must answer these shapes unchanged until each screen moves.
| Route |
Call sites |
What the screen reads |
| A1, A2, A8, A14 |
contexts/AuthContext.tsx, pages/SignInPage.tsx, components/PasswordlessSignIn.tsx, pages/SurveyTakePage.tsx |
access_token, session_token, user.email; both tokens go to localStorage |
| A10, A12 |
pages/GoogleCallbackPage.tsx, pages/AppleCallbackPage.tsx |
access_token and session_token from the redirect's query string |
| A13 |
pages/SurveyTakePage.tsx |
token, kept in memory through setSessionOnlyToken in api/client.ts, then used for SE1 and the session calls |
| A16 |
contexts/AuthContext.tsx, App.tsx (AdminRoute) |
is_admin, roles, locale; is_admin gates every admin screen |
| U1 |
components/ui/LanguagePicker.tsx |
locale |
| D1 |
pages/DashboardPage.tsx |
is_respondent decides which of the two shapes it renders; stats, recent_surveys, organizations, my_surveys |
| S1, S3, S6, S8, S9 |
pages/SurveyBuilderPage.tsx, pages/CreateSurveyPage.tsx, pages/EditSurveyPage.tsx |
id, questions[].id. The builder saves by updating the survey (with status: PUBLISHED when publishing) and then deleting every question and creating it again, so question ids and their translations change on every save |
| S2 |
api/organizations.ts (getSurveys), pages/ImpactReportPage.tsx |
a plain array |
| S4 |
pages/SurveyTakePage.tsx |
id, organization_id, questions[], question_groups, profile_config.geo.enabled, original_options |
| S5 |
pages/AdminTestPage.tsx |
the survey |
| S10 |
pages/SurveyTakePage.tsx |
id (kept for S11), score and group results |
| S11 |
pages/SurveyTakePage.tsx, pages/GoogleCallbackPage.tsx, pages/AppleCallbackPage.tsx |
status only; the response id waits in localStorage across the OAuth round trip |
| S12, S13 |
components/results/MyResultsHistory.tsx |
the list |
| S14, S15 |
pages/SurveyResponsesPage.tsx, pages/SurveySharePage.tsx, pages/OrganizationDetailPage.tsx |
the list; a CSV download |
| S17, S18 |
pages/CompliancePage.tsx |
member_id, status, scores; history entries |
| S19, S20, S21 |
components/TranslationManager.tsx, components/TranslationReview.tsx |
languages, available_languages |
| O1 to O15 |
api/organizations.ts, used by pages/OrganizationDetailPage.tsx, components/organizations/*, components/CustomDomainSection.tsx, contexts/TenantContext.tsx |
O3: a 404 means "no tenant"; O11 and O13 send query parameters; invitation resend is cancel plus invite, done in the client |
| SE1 to SE9 |
api/sessions.ts, used by components/sessions/SessionManager.tsx, components/sessions/SessionHistory.tsx, pages/SurveyTakePage.tsx, pages/SurveySessionPage.tsx |
id, active_session_id, can_attempt |
| AS1 to AS8 |
api/assignments.ts, used by components/AssignmentSection.tsx, components/OverdueSection.tsx, pages/CompliancePage.tsx, pages/DashboardPage.tsx |
the lists |
| T1, T2, T3 |
pages/SurveyTemplatesPage.tsx |
the catalog; the created survey's id |
| IM1, IM2, IM3 |
pages/ImpactReportPage.tsx |
the report; downloads |
| N1, N2, N4 |
pages/NotificationSettingsPage.tsx |
the stored document, including unsubscribe_token |
| K1, K2, K3 |
api/api-keys.ts, components/ApiKeyTable.tsx |
full_key once, key_hint |
| G1 |
components/ProfileStep.tsx |
country_code, region |
| I1, I2 |
pages/AcceptInvitationPage.tsx |
invitation, org_id |
| every error |
26 reads of .detail in 14 files, among them OrganizationDetail.tsx, SessionManager.tsx, SurveyTakePage.tsx, AcceptInvitationPage.tsx, TranslationManager.tsx |
detail as a string. The coded envelope breaks these, which is why the error format at cutover is an open decision on the migration overview |
Client code that calls routes that do not exist: surveyApi.getSurveyQuestions in utils/api.ts (GET /surveys/{id}/questions) and API_ENDPOINTS.sessions.bySurvey (/sessions/survey/{id}). Neither has a caller outside its own file; both are deleted, not ported. On every 401 both clients clear localStorage and send the browser to /auth/signin, so the 30-minute token is felt as a sign-out mid-task.
Routes that return raw dicts
These have no response model, or a model that does not match what the handler returns. Each needs a Zod response schema in the registry, which is also what lets the contract test fail when a response drifts.
| Route |
Today |
Schema to write |
| S7, S9, S13, S16, S21 |
{message} or {message, id}, {message, deleted_count}, {message, language} |
idResponseSchema style, plus counts |
| S19, S20 |
translation result dictionaries |
translationRunResponse, translationEditResponse |
| G1, L1, U1 |
dicts |
geoDetectResponse, languageRegistryResponse, localeResponse |
| SE9 |
response_model=dict |
canAttemptResponse |
| D1 |
two shapes, models imported and unused |
a discriminated union on is_respondent |
| T3 |
the survey document plus questions |
the same surveyResponse as S1 |
| N1, N2 |
the stored document |
notificationPreferencesResponse, without internal fields |
| M1, M2, M3, M5 |
dicts |
healthResponse; test routes get schemas too, so the contract suite can rely on them |
| O14 |
model requires organization_id, not returned (500, inferred) |
the schema plus the field |
| AS5 |
model requires due_date, not returned (500, inferred) |
the schema with nextDueDate |
| SE4 |
writes a response row with _id only |
not a schema problem: the TS service writes one response through the same path as S10 |
New routes the agent layer needs
From Sondely as an Agent, step 1, and the Agent Layer page. All go into the registry, so each becomes a write tool or a read for Faro's Sondely copy and for MCP.
| Method |
Path |
Rbac |
Request |
Response |
Purpose |
| POST |
/api/v1/surveys/validate |
admin, key draft |
a SurveyCreate body |
{valid, problems: [{reason, path, message}]} |
Check a survey before it exists. No write |
| POST |
/api/v1/surveys/{surveyId}/validate |
admin, key read |
none |
as above, plus what publish would refuse |
Check the stored survey |
| POST |
/api/v1/surveys/{surveyId}/publish |
admin, key full |
none |
surveyResponse |
Publish as its own step, refused with INVALID reasons. S6 with status: PUBLISHED runs the same check |
| PATCH |
/api/v1/surveys/{surveyId}/questions/{questionId} |
admin, key draft |
partial QuestionUpdate (the model already exists in backend/app/models/survey.py, no route uses it) |
questionResponse |
Edit in place, so ids and translations survive. Text-only edits under responses are a decision for the Business Logic page |
| PUT |
/api/v1/surveys/{surveyId}/questions/order |
admin, key draft |
{questionIds: [...]}, the full list |
the ordered questions |
Reorder; refused with INVALID when the list misses or adds an id |
| PATCH |
/api/v1/surveys/{surveyId}/groups/{groupId} |
admin, key draft |
name, color, description |
groupResponse |
Groups become a table, so an agent addresses them |
| PUT |
/api/v1/surveys/{surveyId}/groups/order |
admin, key draft |
{groupIds: [...]} |
the ordered groups |
Reorder groups |
| POST |
/api/v1/surveys/{surveyId}/restore |
admin, key full |
none |
surveyResponse |
Undo S7, now a soft delete |
| POST |
/api/v1/surveys/{surveyId}/responses/restore |
owner, key full |
none |
{restoredCount} |
Undo S16, now a soft delete |
| POST |
/api/v1/organizations/{orgId}/restore |
owner, no key |
none |
organizationResponse |
Undo O8, now a soft delete |
| GET |
/api/v1/organizations/{orgId}/audit |
owner, key read |
query: actor, resource, from, to, cursor |
paginated audit rows |
Who or which key changed what, from TourCockpit's Audit model |
| POST |
/api/v1/batch |
as each item |
items with refs |
per item results |
Copied from TourCockpit (src/lib/api/batch.ts): create a survey and its questions in one transaction, one dry run |
| GET |
/api/v1/openapi.json |
public |
none |
OpenAPI 3.1 |
Generated from the registry, as FastAPI's is today |
| POST |
/api/v1/test/reset |
test |
{scope?} |
{ok} |
Truncate every table. Both backends, for the contract suite |
| POST |
/api/v1/test/seed |
test |
a fixture name and overrides |
the created ids |
Seed a known org, users, surveys and responses. Both backends |
| GET |
/api/v1/test/read/{model}/{id} |
test |
none |
the stored row |
Read back what a write stored, for tests that today read Mongo directly. Both backends |
| GET, DELETE |
/api/v1/test/emails |
test |
none |
the intercepted emails |
M2 and M3, moved |
| POST |
/api/v1/test/sign-in |
test |
{email} |
a session |
The e2e sign-in seam, as TourCockpit's api/auth/test-login |
The fake model mode (translation) and the fake geo lookup are environment switches read when ENVIRONMENT=test, not routes. A test route answers 404 in every other environment, and a unit test asserts that for each one, so a wrong environment variable in production cannot open them.