Skip to content

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_ELIGIBLE mention in this file are obsolete — the API no longer accepts or returns those fields. The isAuthorizedPickup flags 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

  1. Error copy fixed: REFERENT_NOT_FOUND.en_US was literally "Guardian not found" and REFERENT_LINK_NOT_FOUND.en_US said "This guardian…". Both now say Referent. If any string matching keyed off the old English copy (it shouldn't — always branch on code), update it.
  2. 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 generic CONFLICT ("Elemento già esistente…") — it answers 409 REFERENT_EMAIL_IN_USE with params.email, and there is a recovery flow (§2).
  3. 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.
  4. Referent links are pickup-authorized by default. isAuthorizedPickup on a referent↔student link now defaults true and every existing link was backfilled true. 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.
  5. 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.
  6. 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.mergeIntoExisting is 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": true inside students to overwrite instead — ask the user first). The response is the existing referent — adopt body.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, entity student_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:

  1. PATCH /referents/:placeholderId { "contacts": { "email": "<existing>" } }409 REFERENT_EMAIL_IN_USEmessages name the address ("{email} appartiene già a un altro referente").
  2. Show a confirm: "Questa email appartiene già a un referente esistente. Collegare quel referente e unire le schede?"
  3. On confirm, resend with the flag inside the contacts block:
PATCH /referents/<placeholderId>
{ "contacts": { "email": "<existing>", "mergeIntoExisting": true } }
  1. 200the response is the SURVIVING referent (the email's owner). The placeholder no longer exists. Adopt body.id; any cached placeholderId now 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/:coParentId and PATCH /referents/:coParentId — unclaimed placeholder or claimed, it makes no difference — now answer the same hidden 404 as 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.mergeIntoExisting is admin-only now — a referent session can never opt into it. Submitting a contacts.email your own record doesn't already have does not produce a merge and does not produce 409 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: false on the specific link (PATCH /referents/:id, students.items[]). A revoked referent chosen as pickup answers the usual 422 PICKUP_PERSON_NOT_ELIGIBLE — keep that error path.
  • Guardians are unchanged: explicit opt-in via their own isAuthorizedPickup; keep filtering referents_and_guardians.guardians[] on the flag.
  • Referent view: no pickup toggle at all (see §1.4). The flag lives under the admin-only referents.students write — permission-driven UI hides it for family sessions automatically if you key affordances off GET /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 rendered messages already 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 201 response carries the same id as 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 carries channel: APP = the family submitted it in the app, PHONE / IN_PERSON = the office ack recorded it on the family's behalf, in which case submittedByName is a staff name. Badge office-recorded rows off channel, 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/overviewstats) — 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 among schoolDays; 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 — totalMinutes is all recorded lesson time, absentMinutes what the child missed, percentage the 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 against presentHours.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)