Skip to content

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 and PICKUP_PERSON_NOT_ELIGIBLE is 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 Referent record, 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 hidden 404s, never 403s 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 the attendance.justify action is granted; read panels exist iff their scope is readable. Never hardcode "referents can X" — with one exception: the profile email field, which /permissions cannot 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 200 with user + 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 — picking referent lands 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.referent is the ReferentResponseDto: scope-grouped identity / contacts / documents / health (all self-editable via PATCH /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 are identity, 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 hidden 404.

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/filesmultipart/form-data, field file, 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

  1. Home / landing — the children switcher (§3); the to-justify list (§4.1) as the primary widget with meta.total as its badge; the notification bell polling unread-count.
  2. Child → attendance — the overview (§4.2): stats header (percentages vs threshold when non-null) + event history; tapping an event or a calendar day opens…
  3. 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 link canWrite and attendance.justify is granted) opening…
  4. 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).
  5. My submissions (§5.2) — history per child, attachments downloadable.
  6. Notification center (§6) — list + mark-read on tap + "mark all read"; rows deep-link to screen 3.
  7. My profile/auth/profile referent snapshot; self-edits via PATCH /referents/:idself-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 hidden 404. Candidate browsing during an open selection window still rides GET /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.composition or subject_groups.composition: GET /homerooms* and GET /subject-groups* answer 403 for a referent session, and the homerooms / subject_groups blocks in the referent's GET /permissions map 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-empty scopes / a true action), never on key presence. The child's own homeroom + subject-group names still arrive on the child profile via the assignment scope (GET /students/:idassignment.homeroom / assignment.subjectGroups) — read them from there, exactly as the student persona does. GET /filters/homerooms?surface=students stays available (auth-only, rows narrowed to the linked children's classes). School is back (2026-08-10): the referent again holds school.configuration: READ, so GET /school answers the scope-grouped school identity for a family session and the school key shows in /permissions. Writes stay admin-only.
  • Filter dropdowns use the common surface policy (breaking 2026-08-26): requests require surface; use surface=students for the family student view's departments, grades, curricula, tracks, and homerooms. Values are projected from linked students through the same StudentsReadPolicy used by the result list. option-blocks and subjects support only surface=subject-groups and therefore project from visible course rows. Response shapes are unchanged, but adding surface is 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.email that differs from the record's current address answers 403 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 from GET /permissions: the referent role still holds referents.contacts WRITE (a parent still edits their own phone numbers and address), so /permissions reports 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 /referents and DELETE /referents/:id have always answered 403 for 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.