The Referent Surface — the complete FE guide¶
STATUS: AUTHORITATIVE for the referent-persona composition. First published 2026-08-05, amended 2026-08-06 (scope removals + the child's curriculum-grid read — see §8), amended 2026-08-10 (pickup removal, breaking on EARLY_EXIT: the
pickupPerson*fields are no longer accepted or returned anywhere andPICKUP_PERSON_NOT_ELIGIBLEis gone — an early exit is date + time + optional reason; build no pickup picker). Owner: backend. Amend in place. The per-module deep contracts live in the attendance guide (§5.8–§5.9), the notifications guide and the session-view guide; this file composes them into one implementable picture for the parent-facing app. If a shape here ever disagrees with a module guide, the module guide wins — and tell the backend, that's a doc bug.
This is everything the FE needs to build the referent (parent/guardian) experience: logging in, finding their children, following each child's attendance, answering the school's justify-requests, and the notification center that closes the loop.
1. Mental model¶
- A referent is a person with links. One
Referentrecord, linked to one or more students (StudentReferentLink). Every referent-side read is record-narrowed to the linked students by the backend — there is no "which children may I see" filtering to do client-side, and no way to see anyone else's child (foreign ids answer hidden404s, never403s that would confirm existence). - Each link carries
canWrite. A read-only link (school-configured) can see everything below but cannot submit justifications or edit the child's record. Fetch the flags once (§3) and hide write affordances per child. - The loop, end to end: the school records an absence or late arrival → ~15 minutes later it becomes an event asking the family for an answer → the referent gets an inbox notification + email → they open the child's day, submit a justification → the event resolves instantly, everywhere. Announcing an absence in advance short-circuits the whole loop: nothing is ever asked for that day.
- Permissions drive the UI, not the role name. Key every affordance off
GET /permissions(and the map returned by view switches): the justify form exists iff theattendance.justifyaction is granted; read panels exist iff their scope is readable. Never hardcode "referents can X" — with one exception: the profile email field, which/permissionscannot express (§9).
2. Session plumbing¶
Base path /api/v1, cookie-based auth (httpOnly access+refresh cookies set
by login; send credentials on every call). Errors use the platform envelope —
{ code, messages: { en_US, it_IT }, params, … } — render messages, branch
on code.
Login¶
POST /auth/login { email, password } — the standard 3-step state machine:
- Referent-only account (the common case): straight
200withuser+ cookies.user.activeProfile: "referent",user.activeView: "referent",user.roles: ["referent"]. - Multi-view account (e.g. a teacher who is also a parent): the response
is the view chooser (
requiresProfileSelection: true,views[],selectionToken). POST the pick to/auth/login/select-profile— pickingreferentlands the session already narrowed to the referent view. - Multi-tenant email: tenant chooser first (
requiresTenantSelection), then the above. See the session-view guide §6 for the full contract.
Mid-session, POST /auth/switch-view { target: "referent" | null } swaps
views; its 200 body is the recompiled /permissions map — replace your
cache with it directly.
Who am I¶
GET /auth/me— lean identity +activeView/availableViews.GET /auth/profile— identity joined with the full person snapshots. For a referent session,profiles.referentis theReferentResponseDto: scope-groupedidentity/contacts/documents/health(all self-editable viaPATCH /referents/:id— email excluded, see §9) plus the links block:
"profiles": {
"referent": {
"id": "…", // the referent id — the self row's own key
"identity": { "firstName": "Maria", "lastName": "Rossi", … },
"contacts": { … },
"students": {
"items": [
{
"studentId": "…",
"relationshipType": "MOTHER", // MOTHER | FATHER | LEGAL_GUARDIAN | GRANDPARENT | OTHER
"isAuthorizedPickup": true,
"isEmergencyContact": true,
"canWrite": true // false = read-only link: hide justify + edit forms for this child
}
]
}
}
}
GET /referents/me answers the same referent record on its own.
3. The children switcher¶
The links block gives ids and flags but no names. Names (and the child's profile) come from the students module, which is record-narrowed for you:
GET /students?page=1&limit=50— paginated{ data, meta }containing only the linked students. Each row is scope-grouped; the referent's readable scopes areidentity,contacts,assignment(read-only: enrollment, department/grade),school_relationships,health,documents,referents_and_guardians,curriculum_selection.GET /students/:id— one child in full (same scoping). Any non-linked id is a hidden404.
Recipe: on session start, fetch /auth/profile (links + flags) and
/students (names) once; join on studentId; render the switcher. A family
has a handful of children — one page is always enough.
4. Following attendance — /attendance/family/*¶
Three reads, all referent+student only (office/teacher sessions get 403
here). Full narrative: attendance guide §5.8.
The 15-minute rule. An absence/late arrival becomes a family-visible event about 15 minutes after the register cell is recorded; a correction inside that window means it never appears. Do not promise real-time. The register content itself (day view slots/cells) is not delayed — only the event objects are.
4.1 GET /attendance/family/to-justify?page=&limit= — the landing widget¶
Every event still awaiting the family's answer, across all linked
children, newest day first, paginated { data, meta } (default limit=20).
meta.total is the action badge; resolved events leave the list.
{
"eventId": "…",
"date": "2026-09-07", // the school day it is about
"eventType": "ABSENT", // ABSENT | LATE_ENTRY
"state": "UNDER_REVIEW", // see the state table below
"studentId": "…",
"studentName": "Ada Lovelace",
"surfacedByName": "Anna Rossi" // who recorded it; null if unresolvable
}
4.2 GET /attendance/family/students/:studentId/overview — one child's year¶
studentId + names + stats + events[] (the family event history, newest
first, same shape as §4.3's dayEvents[]).
"stats": {
"schoolDays": 41, // days with a register entry, minus excluded
"excludedDays": { "suspended": 1, "trip": 2 },
"absent": { "days": 3, "percentage": 7.3 }, // share of schoolDays, one decimal
"lateEntry": { "days": 1, "percentage": 2.4 },
"presentHours": { "absentMinutes": 720, "totalMinutes": 11840, "percentage": 93.9 },
"minPresentHoursPercentage": 75 // the school's threshold, or null
}
Null semantics to render for, not around: every percentage is null
until there is anything to count (start of year); minPresentHoursPercentage
is null when the school set no threshold — hide the comparison in both
cases. Counts are register-derived and grow as registers land. The hours pair
measures recorded time — that is the number the threshold speaks about;
render presentHours.percentage against it.
4.3 GET /attendance/family/students/:studentId/days/:date — one day in full¶
The child's day exactly as the school sees it. date is a calendar day
(YYYY-MM-DD) in the school's timezone — never convert through the
browser clock. Any date is readable; a no-school date answers an empty day,
never an error.
{
"date": "2026-09-07",
"studentId": "…", "firstName": "Ada", "lastName": "Lovelace",
"classifications": ["absent"], // buckets: notTaken | suspended | trip | absent | lateEntry | earlyExit | present
// usually one, can combine (late + left early); never empty — untaken = ["notTaken"]
"items": [ /* the day's slots, period order — see below */ ],
"cells": [ /* raw register cells as written, oldest first */ ],
"dayEvents": [ /* the family event shape — see below */ ],
"justifications": [ /* every submission covering this day, newest first — §5 */ ]
}
items[] — one entry per slot of the child's own timetable that day:
{ kind: "LESSON"|"ACTIVITY", label, wallStart, wallEnd, roomName,
teacherNames[], source, record }. source is the display state:
"RECORDED" (a stored cell), "PROJECTED" (carried forward, DAILY
departments), null (not taken — which is not absent). record carries
the status/time/note when present.
cells[] — the register as written: { recordId, status, time, note,
wallStart, label, recordedByName, recordedAt, … }. A cell survives timetable
edits (frozen snapshot), so render cells as the source of truth for "what
the school wrote" and items for "the day's grid".
dayEvents[] — the family event shape:
{
"eventId": "…",
"date": "2026-09-07",
"eventType": "ABSENT",
"state": "ACKNOWLEDGED",
"notifiedAt": "2026-09-07T08:20:00.000Z", // null until a notification went out
"ackAt": "2026-09-07T10:12:00.000Z", // null while unresolved
"ackChannel": "APP", // PHONE | IN_PERSON | APP
"surfacedByName": "Anna Rossi"
}
States you can receive, and how to render them:
state |
Meaning | Render |
|---|---|---|
UNDER_REVIEW |
Armed, awaiting the family's answer | "please justify" |
NOTIFIED |
The family has been actively notified (inbox + email went out) | same as UNDER_REVIEW — still awaiting an answer |
FOLLOW_UP |
Reminded once, still unresolved | same as UNDER_REVIEW |
ACKNOWLEDGED |
Resolved — ackChannel: "APP" means one of the family's own justifications did it; PHONE/IN_PERSON means the office logged a call/visit |
"justified / handled" |
NOT_REQUIRED |
The day was announced in advance — nothing was ever owed | "already justified" |
In-grace, cancelled and superseded events are never present on this
surface, and there is no ackReason/ackByName/lastContactAt (office
internals).
4.4 Errors on the reads¶
| Case | Answer |
|---|---|
studentId not linked, someone else's, or nonexistent |
404 NOT_FOUND — indistinguishable on purpose |
malformed :date |
400 VALIDATION_FAILED |
office/teacher session on any /family route |
403 |
5. Answering the school — justifications¶
The family's one write path. Submitting is final — no edit, no delete,
no school approval step. Effects are immediate: every matching open event
flips to ACKNOWLEDGED/APP on the spot (it leaves the to-justify list and
the office worklist in the same instant), and a day announced in advance is
born NOT_REQUIRED when the register later records it. Full narrative:
attendance guide §5.9.
Matching is by kind and date — there is no event id in the request. To
"justify" a to-justify item, pre-fill the form from the item's studentId +
date + eventType and submit. An ABSENCE covers only ABSENT; a
LATE_ENTRY covers only LATE_ENTRY; an
EARLY_EXIT covers nothing (informational — no event type exists for early
exits). If an advance announcement and the later register produce different
ABSENT/LATE_ENTRY types, the recorded type creates a new to-justify item
after the arm window.
5.1 POST /attendance/family/justifications¶
// ABSENCE — a date range + reason, no time:
{ "studentId": "…", "kind": "ABSENCE",
"startDate": "2026-09-10", "endDate": "2026-09-12", "reason": "influenza" }
// LATE_ENTRY — one day + arrival time + reason:
{ "studentId": "…", "kind": "LATE_ENTRY",
"startDate": "2026-09-10", "time": "09:15", "reason": "medical appointment" }
// EARLY_EXIT — one day + leave time; reason optional (pickup selector
// removed 2026-08-10):
{ "studentId": "…", "kind": "EARLY_EXIT",
"startDate": "2026-09-10", "time": "14:00" }
The kind-conditional shape is validated strictly — mirror it in the form:
| Field | ABSENCE |
LATE_ENTRY |
EARLY_EXIT |
|---|---|---|---|
endDate |
required, ≥ startDate |
omit (or equal) | omit (or equal) |
time (HH:mm) |
forbidden | required | required |
reason |
required | required | optional |
Any mismatch — and any range outside the school year — is
400 VALIDATION_FAILED. 201 answers the full JustificationDto (id, echo
of the fields, attachments: [], submittedByName, submittedAt).
No pickup picker. The "who picks the student up" selector was removed by
product decision 2026-08-10 — an EARLY_EXIT no longer names a person, and the
API neither accepts nor returns the pickupPerson* fields.
Who may submit: a referent whose link has canWrite. A read-only link is
403; an unlinked student is a hidden 404; a student session cannot
submit at all (403).
5.2 GET /attendance/family/justifications?page=&limit=&studentId=¶
The caller's submissions this school year, newest first, paginated;
studentId narrows to one child. Rows are the same JustificationDto,
attachments included. The same content also appears per-day in the day
view's justifications[] (§4.3) — every submission whose range covers that
day, in the identical block the school sees.
5.3 Attachments¶
Evidence documents (medical certificate, booking confirmation) on ABSENCE
and LATE_ENTRY only. Uploading changes no event — matching already
happened at submit; a certificate arriving days later is the expected flow.
POST /attendance/family/justifications/:id/files—multipart/form-data, fieldfile, one per call, max 5 per justification, 10 MB, PDF/JPEG/PNG. Same writer gate as the POST.GET /attendance/family/justifications/:id/files/:fileId— mints a signed URL{ url, fileName, mimeType, byteSize, expiresAt }; fetch the URL directly, it expires.
5.4 Errors on the write path¶
| Case | Answer |
|---|---|
| kind-shape mismatch, out-of-year range | 400 VALIDATION_FAILED |
read-only link (canWrite false) |
403 |
| student session submitting | 403 |
unlinked studentId in the body |
404 NOT_FOUND |
| justification not yours / nonexistent | 404 JUSTIFICATION_NOT_FOUND |
file on an EARLY_EXIT |
422 JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED |
| 6th file | 409 JUSTIFICATION_ATTACHMENT_LIMIT |
6. The notification center¶
Every user has a private inbox; for referents it is where the attendance loop's nudges land. All endpoints are auth-only and self-scoped by construction — no permission scope to check, no cross-user view. Full contract: the notifications guide.
| Verb + path | Purpose | Answer |
|---|---|---|
GET /notifications |
The inbox, newest first | paginated { data, meta } (default limit=20) |
GET /notifications?unread=true |
Only unread rows | same shape |
GET /notifications/unread-count |
The badge — poll every 30–60 s while the app is open (no socket/SSE exists) | { count } |
PATCH /notifications/:id/read |
Mark one read — idempotent, keeps the first readAt |
the updated row |
POST /notifications/read-all |
Mark everything read | { updated } |
A row is { id, kind, payload, createdAt, readAt } (readAt: null =
unread; no tenant/recipient echo — the caller is the recipient). A
PATCH …/read on a missing or not-own id answers
404 NOTIFICATION_NOT_FOUND — treat as gone, refresh the list. The
backend never sends display copy: render localized text from kind +
payload, and render unknown kinds harmlessly (generic row) — new kinds
will appear without an FE release.
The two kinds today¶
Both share one payload:
{ "eventId": "…", "studentId": "…", "studentName": "Ada Lovelace",
"date": "2026-09-07", "eventType": "ABSENT" } // ABSENT | LATE_ENTRY
| Kind | When | Copy shape |
|---|---|---|
attendance.event |
An event passed its grace unanswered — the school asks the family to justify | "{studentName} was marked {absent/late} on {date} — please review" |
attendance.reminder |
One reminder delay later, still unresolved (sent at most once per event) | "Reminder: … on {date} is still awaiting a justification" |
Copy MUST lead with the date — events can be backdated (the school
filling in last Tuesday), so "absent today" is wrong copy. Deep-link both
kinds to the child's day view for studentId + date (§4.3), where the
justify form pre-fills (§5).
Two badges, two meanings — keep both. to-justify meta.total counts
items needing action (drops when resolved, regardless of reading);
unread-count counts unread inbox rows (drops only on mark-read). Marking
a notification read changes nothing on the attendance side, and resolving an
event does not mark its notification read.
Referents also receive a templated email per notification, in the school's language — backend-owned end to end, nothing for the FE to build.
7. Suggested screen map¶
- Home / landing — the children switcher (§3); the to-justify list
(§4.1) as the primary widget with
meta.totalas its badge; the notification bell pollingunread-count. - Child → attendance — the overview (§4.2): stats header (percentages vs threshold when non-null) + event history; tapping an event or a calendar day opens…
- Child → day (§4.3) — the slots grid, the recorded cells, the day's
events with their states, the day's
justifications[]; a "justify" button (iff the linkcanWriteandattendance.justifyis granted) opening… - Justify form (§5.1) — kind selector driving the conditional fields; pre-filled when arriving from a to-justify item or a notification deep link; attachment upload afterwards (§5.3).
- My submissions (§5.2) — history per child, attachments downloadable.
- Notification center (§6) — list + mark-read on tap + "mark all read"; rows deep-link to screen 3.
- My profile —
/auth/profilereferent snapshot; self-edits viaPATCH /referents/:id— self-only, and the email field is read-only (§9).
8. What is NOT here¶
- No real-time. Events appear ~15 min after recording; the badge is a poll. No sockets, no push (Web Push/mobile push are designed as future channels of the same notifications — do not build against them).
- No edit/delete on justifications, no school-side approval to track.
- No self-justification by students — a student session reads §4 for themselves but cannot submit.
- Other referent-visible surfaces (out of scope here, own guides/API):
the child's grades read (
grade_entries), the published timetable read, and the curriculum-selection window flow (US-32). - The child's curriculum grid (2026-08-06 — new):
GET /curricula/:id(and the list) now answer a referent session, narrowed to curricula a linked child holds a current-or-pending selection on — use it to render the child's full study-plan grid. Anything else (unselected curricula, other families' plans) is a hidden404. Candidate browsing during an open selection window still ridesGET /students/:id/curriculum-selection(the eligible curriculum trees), not this endpoint. - No Classes / Courses sections (2026-08-06 — grants removed; School
re-granted 2026-08-10, see below). The referent role no longer holds
homerooms.compositionorsubject_groups.composition:GET /homerooms*andGET /subject-groups*answer403for a referent session, and thehomerooms/subject_groupsblocks in the referent'sGET /permissionsmap are empty shells{ scopes: {}, actions: { …all false } }— since 2026-08-18 every catalogue entity is ALWAYS present for every persona (reversing the short-lived 2026-08-10 omission), so key section visibility on the block's content (non-emptyscopes/ atrueaction), never on key presence. The child's own homeroom + subject-group names still arrive on the child profile via theassignmentscope (GET /students/:id→assignment.homeroom/assignment.subjectGroups) — read them from there, exactly as the student persona does.GET /filters/homerooms?surface=studentsstays available (auth-only, rows narrowed to the linked children's classes). School is back (2026-08-10): the referent again holdsschool.configuration: READ, soGET /schoolanswers the scope-grouped school identity for a family session and theschoolkey shows in/permissions. Writes stay admin-only. - Filter dropdowns use the common surface policy (breaking 2026-08-26):
requests require
surface; usesurface=studentsfor the family student view's departments, grades, curricula, tracks, and homerooms. Values are projected from linked students through the sameStudentsReadPolicyused by the result list.option-blocksandsubjectssupport onlysurface=subject-groupsand therefore project from visible course rows. Response shapes are unchanged, but addingsurfaceis mandatory; no client-side visibility filtering is needed.
9. Editing your own profile — the write contract (2026-08-10)¶
PATCH /referents/:id is how a referent edits their own profile
(identity/contacts/documents/health — see §2's "Who am I"). Three
rules govern it, all enforced server-side regardless of what the FE does:
- Self-only. A referent session resolves and writes exactly one referent
record — its own. Every other id is a hidden
404— identical to a stranger's record, including another parent/guardian linked to the same child. (If your FE built against the co-parent-editing flow described in the QA-bugs guide §2.3 — that capability was removed; see that guide's updated §2.3.) - The email field is read-only for family and Front Office sessions. Submitting a
contacts.emailthat differs from the record's current address answers403 REFERENT_EMAIL_READ_ONLY— only an administrator can change a referent's login email. Re-submitting the current address succeeds as a no-op (matched case/whitespace-insensitively), so a full-form PATCH that echoes the unchanged email is safe — no need to strip the field before sending. This cannot be derived fromGET /permissions: the referent role still holdsreferents.contactsWRITE (a parent still edits their own phone numbers and address), so/permissionsreports that scope as writable even though the email column inside it is not. Hard-code the email field as read-only on the referent's own-profile form for every family session — do not key it off the grants payload the way every other affordance in this guide is keyed (§1). - No add/remove-referent affordances.
POST /referentsandDELETE /referents/:idhave always answered403for a referent session — there is no "add a co-parent" or "remove a referent" action to surface on the family profile screen.
Role-matrix v3 access note (2026-08-20)¶
HR Manager and Front Office can browse the tenant referent directory and read referent documents; Front Office can edit granted contact fields but the email identity remains read-only. Curriculum coordinators see only referents linked to students whose effective selection is in one of their assigned curricula, and they do not receive referent-document access. Teacher/family non-self document slots remain masked as null.