This document describes the simplified frontend/backend contract for the examination form.
The goals are:
- keep the frontend structure hardcoded
- keep the backend as the source of truth for values, visibility, status, and finalization
- avoid arbitrary dynamic section or subsection identifiers
- keep autosave safe with optimistic concurrency
- keep the payload predictable and easy to map to the database
- the frontend keeps the route structure and UI layout
- the backend exposes one concrete endpoint per section
- section keys are a closed enum, not arbitrary strings
- subsection keys are a closed enum per section, not arbitrary strings
- the backend returns only the subsections the current client may see
- section saves always send the whole visible section snapshot
- section saves use
PUT, not sparsePATCH - all section reads and writes carry a
revision
These are the canonical section keys used in payloads:
type ExaminationSectionKey =
| "anamneza"
| "predbezne-testy"
| "vlastni-korekce-a-obj-ref"
| "subjektivni-refrakce"
| "binokularita"
| "mereni-blizko"
| "zaver";Important:
- these keys are part of the API contract
- they are not user-provided free-form values
- the backend should model them as code constants / enums
Subsection keys should also be code-defined, per section.
Example for anamneza:
type AnamnesisSubsectionKey =
| "subjective-history"
| "correction-history"
| "digital-history"
| "social-history"
| "medical-history";Important:
- subsection keys are payload contract values, not arbitrary database strings
- the backend should validate them against the section they belong to
- the frontend should map them to hardcoded subsection components
type ExaminationSectionStatus = "not_started" | "incomplete" | "error" | "complete";Meaning:
not_started- no meaningful answer exists in the section
incomplete- no invalid values exist, but one or more required visible fields are missing
error- at least one entered value is invalid
complete- all required visible fields are present and valid
Important:
- missing required values must produce
incomplete, noterror erroris only for invalid entered values
type ExaminationFieldIssueKind = "invalid" | "missing_required";
type ExaminationFieldIssueDto = {
propertyPath: string;
message: string;
kind: ExaminationFieldIssueKind;
code?: string;
hint?: string;
};Rules:
propertyPathmust use a stable, documented dot-path format- array items should use numeric segments, for example:
digitalHistory.deviceRows.0.dailyHourssocialHistory.activityRows.1.note
invalidmeans a provided value is not acceptablemissing_requiredmeans a required visible field has no value
This part is critical.
If a scalar field is visible and empty, it must be sent and returned as null.
Examples:
- text input empty ->
null - date input empty ->
null - select empty ->
null - number-like scalar empty ->
null
If a collection field is visible and empty, it must be sent and returned as [].
Hidden subsection
If a subsection is hidden for the current client:
- it is omitted from
subsections - it is omitted from
values
Because omission is ambiguous. It could mean:
- hidden
- unchanged
- not loaded
- not included by mistake
- intentionally cleared
Using null and [] keeps the contract explicit.
To avoid stale saves overwriting newer changes, every read and write includes a revision.
Rules:
GETresponses include the currentrevisionPUTrequests must send the last knownrevision- if the submitted
revisionis stale, the backend rejects the write
Suggested stale-write status code:
409 Conflict
Returns exam-wide metadata for the sidebar and layout.
Endpoint
GET /api/examinations/{examinationId}/form-shellResponse
{
"examinationId": "ex_123",
"revision": 7,
"state": "draft",
"sections": [
{
"key": "anamneza",
"status": "incomplete",
"subsections": [
"subjective-history",
"correction-history",
"digital-history",
"social-history"
]
},
{
"key": "predbezne-testy",
"status": "not_started",
"subsections": [
"motility-pupils",
"cover-test"
]
}
]
}Meaning:
sections[].keyis a closed section enum valuesections[].subsectionscontains only the subsection keys visible to the current client- the frontend uses this for navigation, visibility filtering, and read-only state
The backend should keep one concrete endpoint per section.
Examples:
GET /api/examinations/{examinationId}/anamnesisPUT /api/examinations/{examinationId}/anamnesisGET /api/examinations/{examinationId}/preliminary-testsPUT /api/examinations/{examinationId}/preliminary-testsGET /api/examinations/{examinationId}/custom-correction-and-objective-refractionPUT /api/examinations/{examinationId}/custom-correction-and-objective-refraction
Important:
- the frontend may keep a generic route like
/:section - the backend should not expose one generic
/{sectionName}resource whose DTO changes dynamically - each backend route should map to one concrete DTO, provider, and processor
Loads one section.
Endpoint
GET /api/examinations/{examinationId}/anamnesisResponse
{
"examinationId": "ex_123",
"revision": 7,
"section": "anamneza",
"status": "incomplete",
"subsections": [
"subjective-history",
"correction-history",
"digital-history",
"social-history"
],
"values": {
"subjectiveHistory": {
"reason": "Blurred vision",
"eyeHistory": null
},
"correctionHistory": {
"glassesSince": null,
"correctionNote": null,
"lastOphthalmologist": "2025-10-12",
"ophthalmologistNote": null
},
"digitalHistory": {
"deviceRows": []
},
"socialHistory": {
"activityRows": [],
"driverValue": null,
"driverNote": null
}
},
"fieldIssues": [
{
"propertyPath": "correctionHistory.lastOphthalmologist",
"kind": "invalid",
"message": "Date cannot be in the future"
}
]
}Rules:
subsectionscontains only visible subsection keys for this sectionvaluescontains only visible subsection objects- visible empty scalar fields must be present as
null - visible empty arrays must be present as
[] - hidden subsections are omitted entirely
Saves one section.
The frontend may trigger autosave after editing one field, but the request payload must still contain the whole visible section snapshot.
This means:
- user edits one field locally
- frontend serializes the entire current visible section state
- frontend sends that full visible section in one
PUT
This keeps backend merge logic simple and makes validation deterministic.
Endpoint
PUT /api/examinations/{examinationId}/anamnesisRequest
{
"revision": 7,
"values": {
"subjectiveHistory": {
"reason": "Blurred vision",
"eyeHistory": null
},
"correctionHistory": {
"glassesSince": null,
"correctionNote": null,
"lastOphthalmologist": "2025-10-12",
"ophthalmologistNote": null
},
"digitalHistory": {
"deviceRows": []
},
"socialHistory": {
"activityRows": [],
"driverValue": null,
"driverNote": null
}
}
}Response
{
"examinationId": "ex_123",
"revision": 8,
"section": "anamneza",
"status": "incomplete",
"subsections": [
"subjective-history",
"correction-history",
"digital-history",
"social-history"
],
"values": {
"subjectiveHistory": {
"reason": "Blurred vision",
"eyeHistory": null
},
"correctionHistory": {
"glassesSince": null,
"correctionNote": null,
"lastOphthalmologist": "2025-10-12",
"ophthalmologistNote": null
},
"digitalHistory": {
"deviceRows": []
},
"socialHistory": {
"activityRows": [],
"driverValue": null,
"driverNote": null
}
},
"fieldIssues": [],
"sections": [
{
"key": "anamneza",
"status": "incomplete",
"subsections": [
"subjective-history",
"correction-history",
"digital-history",
"social-history"
]
},
{
"key": "predbezne-testy",
"status": "not_started",
"subsections": [
"motility-pupils",
"cover-test"
]
}
]
}{
"error": "revision_conflict",
"message": "The examination was changed by a newer save.",
"currentRevision": 8
}Finalization runs a full examination validation and locks the examination.
Endpoint
POST /api/examinations/{examinationId}/finalize{
"examinationId": "ex_123",
"revision": 9,
"state": "finalized",
"sections": [
{
"key": "anamneza",
"status": "complete",
"subsections": [
"subjective-history",
"correction-history",
"digital-history",
"social-history"
]
},
{
"key": "predbezne-testy",
"status": "complete",
"subsections": [
"motility-pupils",
"cover-test"
]
}
]
}Suggested status code:
422 Unprocessable Entity
{
"examinationId": "ex_123",
"revision": 8,
"state": "draft",
"sections": [
{
"key": "anamneza",
"status": "complete",
"subsections": [
"subjective-history",
"correction-history",
"digital-history",
"social-history"
]
},
{
"key": "predbezne-testy",
"status": "incomplete",
"subsections": [
"motility-pupils",
"cover-test"
]
}
],
"sectionIssues": [
{
"section": "predbezne-testy",
"fieldIssues": [
{
"propertyPath": "coverTest.distance",
"kind": "missing_required",
"message": "This field is required"
}
]
}
]
}To keep the API and backend implementation maintainable:
- do not build persistence around generic rows like:
exam_idsection_keysubsection_keyfield_keyvalue
- avoid turning the form into an EAV model
- prefer typed section data in the domain model
- persist section data either:
- in typed columns / owned tables per section, or
- as structured JSON per section
Important:
- subsection keys are API contract values
- they do not need to become the primary persistence model
subsectionsis visibility metadata derived by backend rules for the current client
- section keys are closed contract values
- subsection keys are closed contract values per section
- the shell returns
subsections, notvisibleSubsections - the backend exposes concrete section endpoints
- section reads and writes include
revision - saves use full visible-section
PUT - visible empty scalar fields use
null - visible empty collections use
[] - hidden subsections are omitted
- finalization is a dedicated locking action