Referent QA batch — the complete FE guide (2026-08-07)¶
STATUS: AUTHORITATIVE for everything in this PR. Self-contained by design — implement from this file alone. It extends (and where marked BREAKING, overrides) the referent surface guide, the family attendance guide and the attendance module guide; those files are deliberately untouched. Owner: backend. Amend in place.
Out of scope: the homeroom↔course-link work shipping alongside this PR has its own guides (link, usage).
SUPERSEDED IN PART, 2026-08-10 (pickup removal). Product removed the EARLY_EXIT pickup-person selector entirely: §3 below and every
pickupPerson*/PICKUP_PERSON_NOT_ELIGIBLEmention in this file are obsolete — the API no longer accepts or returns those fields. TheisAuthorizedPickupflags still exist on links/guardians (admin-editable) but gate nothing. The family attendance guide is the current contract.
This batch closes the QA bugs on the referent role: editing referents from the
student page (duplicate-email dead ends, "guardian not found"), pickup
authorization for parents, contradictory attendance communications, and the
office's blindness to what families submit. Base path /api/v1, cookie auth,
platform error envelope ({ code, messages: { en_US, it_IT }, … }) — render
messages, branch on code. params never reach the wire: every detail
you need (the conflicting email, the blocking kind and dates) is already woven
into the localized messages — render them, never parse them.
1. Breaking changes — check your code for these¶
- Error copy fixed:
REFERENT_NOT_FOUND.en_USwas literally "Guardian not found" andREFERENT_LINK_NOT_FOUND.en_USsaid "This guardian…". Both now say Referent. If any string matching keyed off the old English copy (it shouldn't — always branch oncode), update it. - Referent email collisions are a structured 409 now. Changing a
referent's email (
PATCH /referents/:id) to an address that already belongs to another referent of the school no longer answers the genericCONFLICT("Elemento già esistente…") — it answers409 REFERENT_EMAIL_IN_USEwithparams.email, and there is a recovery flow (§2). - Justification resubmissions changed meaning. Submitting the same kind
for the same day used to create a duplicate — it now replaces the
previous communication (same
id). A day covered by an ABSENCE now refuses new communications (409 JUSTIFICATION_CONFLICT, §4). If your form treated every 201 as "a new row exists", re-read §4. - Referent links are pickup-authorized by default.
isAuthorizedPickupon a referent↔student link now defaultstrueand every existing link was backfilledtrue. Guardian contacts are unchanged (explicit opt-in). Remove/hide any pickup toggle in the referent-facing view — it was never writable by the referent (admin-only flag) and is now on by default; the admin view keeps the toggle as a revoke affordance. - Referent emails are normalized. Every write path lowercases+trims the email before storing/matching, and existing rows were normalized by migration. Comparisons client-side should use the lowercase form the API now returns.
- QA bug 4's co-parent capability and the family-side email-merge are
removed (2026-08-10 product decision). A referent session can no longer
read or edit any referent record but its own, and
contacts.mergeIntoExistingis admin-only now. See §2.3 for the full replacement contract — if your FE started building the "complete the co-parent's profile" flow against the text that used to be there, stop; it does not ship.
2. Editing referents from the student page (QA bugs 1 + 4)¶
2.1 "Add this student to an existing parent" — the canonical flow¶
To attach a student to a parent that may already exist, always use
POST /referents (create-or-link by email) — never an edit form:
POST /referents
{
"students": { "studentId": "<the student>", "relationshipType": "FATHER" },
"contacts": { "email": "papa.rossi@example.com", "cellPhone": "+39 333 1234567" },
"identity": { "firstName": "Paolo", "lastName": "Rossi" } // optional
}
- Email unknown → a new referent is created with these fields.
- Email exists → the existing referent is linked to the student and its
profile is left untouched (add
"replaceProfile": trueinsidestudentsto overwrite instead — ask the user first). The response is the existing referent — adoptbody.id. - Onboarding/invitation status inherits automatically: invitations are keyed on the referent id, so the second student's page immediately shows the same status the first one does. Nothing to do client-side.
- Already linked to this student →
409(generic, entitystudent_referent_links) — treat as "already there".
2.2 Editing a referent's email onto an existing one — the merge flow¶
When a user edits a referent (typically an email-only placeholder from the import) and sets the email of a parent that already exists:
PATCH /referents/:placeholderId { "contacts": { "email": "<existing>" } }→409 REFERENT_EMAIL_IN_USE—messagesname the address ("{email} appartiene già a un altro referente").- Show a confirm: "Questa email appartiene già a un referente esistente. Collegare quel referente e unire le schede?"
- On confirm, resend with the flag inside the contacts block:
PATCH /referents/<placeholderId>
{ "contacts": { "email": "<existing>", "mergeIntoExisting": true } }
200— the response is the SURVIVING referent (the email's owner). The placeholder no longer exists. Adoptbody.id; any cachedplaceholderIdnow answers 404.
Merge semantics (what the user should be told): the placeholder's student links move onto the survivor (links the survivor already had keep their flags); profile fields you sent — and the placeholder's own — fill only empty fields of the survivor (an existing account's data is never overwritten); the placeholder's pending invitation is discarded (the survivor's onboarding status carries).
Merge is refused (the 409 stays even with the flag) when the edited record has already activated an account — two real accounts never auto-merge; that is an admin/support case.
2.3 Co-parent editing and the family email-merge — REMOVED (2026-08-10)¶
Product decision, 2026-08-10: both capabilities this section used to describe are removed. Do not build against them.
- Reading/editing a co-parent is gone. A referent session resolves and
writes only its own referent record.
GET /referents/:coParentIdandPATCH /referents/:coParentId— unclaimed placeholder or claimed, it makes no difference — now answer the same hidden404as any stranger's record. The "fill in the co-parent's missing cellphone" flow this section used to describe (identity/contacts PATCH, masked document fields, closed document routes) no longer exists in any form — there is nothing left to render. - The family-bounded email-merge is gone.
contacts.mergeIntoExistingis admin-only now — a referent session can never opt into it. Submitting acontacts.emailyour own record doesn't already have does not produce a merge and does not produce409 REFERENT_EMAIL_IN_USE— see below, it 403s before the collision check ever runs.
The email field is read-only for the family. PATCH /referents/:id on a
referent's own record rejects a contacts.email that differs from the
currently stored address with 403 REFERENT_EMAIL_READ_ONLY — only the
school can change a referent's login email. Re-submitting the record's
current address (matched case/whitespace-insensitively) is accepted as a
no-op, so a full-form PATCH that echoes the unchanged email does not need the
field stripped before sending — though the field itself should still render
read-only for the family. Don't derive that from GET /permissions: the
referent role still holds referents.contacts WRITE (parents still edit
phone/address), so the grants payload alone won't tell you to lock the email
field — see the referent-surface guide §9 for the full write contract.
2.4 Student create/import phone mapping¶
POST /students' referent block now accepts cellPhone (mobile — the field
profile completeness requires) alongside homePhone:
"referents_and_guardians": {
"referents": [{ "email": "mama@example.com", "cellPhone": "+39 333 0000000" }]
}
The CSV import's referent_cell_phone_1/2 columns now land in cellPhone
(historically they mis-landed in homePhone; existing rows were migrated).
Imported referents therefore stop being born with a "missing cellphone"
completeness flag.
3. Pickup authorization (QA bug 2)¶
- Referents: every linked referent is an eligible EARLY_EXIT pickup
person by default. The EARLY_EXIT picker (child's record →
referents_and_guardians.items[]) no longer needs to filter referents on any flag — offer them all; "myself" is the caller's own referent id from/auth/profile. - Revocation stays possible: an admin sets
isAuthorizedPickup: falseon the specific link (PATCH /referents/:id,students.items[]). A revoked referent chosen as pickup answers the usual422 PICKUP_PERSON_NOT_ELIGIBLE— keep that error path. - Guardians are unchanged: explicit opt-in via their own
isAuthorizedPickup; keep filteringreferents_and_guardians.guardians[]on the flag. - Referent view: no pickup toggle at all (see §1.4). The flag lives under
the admin-only
referents.studentswrite — permission-driven UI hides it for family sessions automatically if you key affordances offGET /permissions.
4. The justify form — consistency rules (QA bugs 3 + 5)¶
POST /attendance/family/justifications no longer accepts contradictions.
The request/response shapes are unchanged; three outcomes are new.
4.1 The matrix¶
For the submitted day(s) of one child:
| You submit ↓ / day already has → | an ABSENCE covering it | the same kind | the other single-day kind |
|---|---|---|---|
| ABSENCE | ❌ 409 JUSTIFICATION_CONFLICT |
♻ replaces it | ♻ replaces it |
| LATE_ENTRY | ❌ 409 JUSTIFICATION_CONFLICT |
♻ replaces it (same id) |
✅ coexists |
| EARLY_EXIT | ❌ 409 JUSTIFICATION_CONFLICT |
♻ replaces it (same id) |
✅ coexists |
- ❌ Blocked (
409 JUSTIFICATION_CONFLICT): the day is already covered by an absence, or two absences would overlap. The renderedmessagesalready name the blocker and its dates — "Una comunicazione ABSENCE copre già questo periodo (2026-09-09 – 2026-09-11)" — render them as the form error. For an overlapping absence, guide the user to submit only the non-overlapping remainder (e.g. existing Mon–Wed, child still sick → submit Thu–Fri). - ♻ Replace ("last wins"): the
201response carries the sameidas the previous communication with the new substance; attachments already uploaded stay attached. Message the user accordingly ("la comunicazione precedente è stata sostituita"), and refresh any cached list — do not append. An ABSENCE submitted over a day that had a late-entry/early-exit announcement removes those (and their attachments) — the absence is the whole day's story now. - ✅ Coexist: a late arrival plus an early exit on one day is legitimate — both render on the day.
4.2 What this fixes¶
Absence + late for the same day can no longer both exist (either order: the late is refused after an absence, or the absence replaces the late). A wrong submission is no longer forever: resubmit the same kind to correct time or reason, or an absence to escalate.
5. Office visibility of family communications (QA bug 5)¶
5.1 GET /attendance/justifications — the new office list¶
Every family communication of the school year, newest first, paginated
{ data, meta } (default limit=20). Same access as the follow-up worklist
(office roles; family and teacher sessions get 403).
| Query param | Meaning |
|---|---|
date |
only communications covering that YYYY-MM-DD day — a multi-day absence matches every day it spans |
studentId |
one student |
kind |
ABSENCE | LATE_ENTRY | EARLY_EXIT |
Each row is the family's JustificationDto plus studentName:
{
"id": "…", "studentId": "…", "studentName": "Ada Lovelace",
"kind": "ABSENCE", "startDate": "2026-09-10", "endDate": "2026-09-12",
"time": null, "reason": "influenza",
"pickupPersonType": null, "pickupPersonId": null, "pickupPersonName": null,
"attachments": [{ "fileId": "…", "fileName": "certificato.pdf" }],
"submittedByName": "Maria Rossi", "submittedAt": "2026-09-09T18:00:00.000Z"
}
Amended 2026-08-10 — two changes since this sample was written. The three
pickupPerson*fields above are gone (product removed the pickup selector), and every row now carrieschannel:APP= the family submitted it in the app,PHONE/IN_PERSON= the office ack recorded it on the family's behalf, in which casesubmittedByNameis a staff name. Badge office-recorded rows offchannel, never off the name. Authoritative copy: the attendance module guide §5.5 and §5.9.
Attachment downloads:
GET /attendance/justifications/:id/files/:fileId → signed-URL envelope
(this list is what finally makes those ids discoverable office-side).
Suggested placement: a "Comunicazioni famiglie" tab on the register day view (filtered to the day) and/or on the student panel (filtered to the student). This is how the office sees advance announcements — a pre-announced absence produces nothing on the worklist by design (nothing is owed), so this list is where "the parents already told us" lives.
5.2 The register drill-down already carries them¶
GET /attendance/rows (per-class register) responds with
students[].justifications[] — the same blocks, per student, for the queried
day. Render them on the register UI (QA looked for parent communications
on the register and saw nothing — the data was here). The register cells
themselves are never auto-written from communications: an announced absent
day still shows "not taken" until a teacher records it — that is by design;
the justifications[] block alongside is the parents' side of the story.
6. The family attendance counters, explained (QA bug 6)¶
Copy for the overview widgets (GET /attendance/family/students/:id/overview
→ stats) — also usable verbatim as tooltip text:
schoolDays— "Giorni di scuola": days so far on which the child's register was actually taken (at least one recorded entry), excluding suspension/trip days. A running counter of lived school days — not the year's planned total. It grows as teachers record.excludedDays { suspended, trip }— "esclusi N giorni": whole days deliberately excluded from every count because lessons were suspended or the class was on a trip. They count neither as presence nor absence.absent { days, percentage }: full-day absences amongschoolDays;percentage=days / schoolDays, one decimal.lateEntry { days, percentage }: days with a late arrival, same denominator.presentHours { absentMinutes, totalMinutes, percentage }: the minute-level view over recorded lessons only —totalMinutesis all recorded lesson time,absentMinuteswhat the child missed,percentagethe share of recorded time present. This is the number the school's minimum-attendance threshold refers to.minPresentHoursPercentage: the school's per-department minimum present-hours threshold, to compare againstpresentHours.percentage.null= the school set no threshold — hide the comparison.
Every percentage is null on an empty denominator (start of year) — render
"no data yet", never 0%.
7. Quick reference — new/changed error codes¶
The envelope carries code + localized messages (params never reach the
wire — the details below are already rendered into the message text).
| Code | HTTP | Where | Message carries | FE reaction |
|---|---|---|---|---|
REFERENT_EMAIL_IN_USE |
409 | PATCH /referents/:id |
the colliding email | admin/office-only now — offer the merge confirm (§2.2); a family session in practice never reaches this code, it 403s first (§2.3) — the one exception is a record whose stored address predates normalization, where re-sending the same address unchanged can still surface this 409. Either way a family session cannot change the address: render the code, never offer the merge outside admin/office |
REFERENT_EMAIL_READ_ONLY |
403 | PATCH /referents/:id |
— | family sessions only — the email field is read-only for referents; render it non-editable, don't retry with a different value (§2.3) |
JUSTIFICATION_CONFLICT |
409 | POST /attendance/family/justifications |
the blocking kind + its date range | render it; suggest the remainder for absence overlaps (§4.1) |
REFERENT_NOT_FOUND |
404 | referent routes | — | copy fixed ("Referent not found"); also what a family session gets for any referent id but its own (§2.3) |
PICKUP_PERSON_NOT_ELIGIBLE |
422 | EARLY_EXIT submit | — | unchanged — now only fires for revoked referents / non-opted-in guardians (§3) |