Attendance — the family surface + notifications, the FE guide¶
STATUS: AUTHORITATIVE for FE integration of the attendance family loop. First published 2026-08-05. Owner: backend. Amend in place.
Updated 2026-08-10 — families can now revise and withdraw communications that have not started yet, and remove attachments from any of them. Four new routes plus two additive fields (
fromon the list query,canEditon every justification row). Nothing existing breaks: no response shape changed and no field was removed, so current screens keep working untouched. New work is in §3 (routes 6, 7, 9), §5.2 (from+ the two-list landing pattern), §5.3 (documents ignore the edit window) and §5.5 (the whole revise/withdraw contract — start there).Updated 2026-08-10, third batch (pickup removal) — BREAKING on EARLY_EXIT. Product removed the "who picks the student up" selector.
pickupPersonTypeandpickupPersonIdare no longer accepted onPOST/PATCHjustifications (sending them is now a400— the API rejects unknown fields), andpickupPersonType/pickupPersonId/pickupPersonNameare gone from every response (family list, mutation responses, thejustifications[]day blocks).PICKUP_PERSON_NOT_ELIGIBLEno longer exists. An EARLY_EXIT is now juststartDate+time(+ optionalreason). Delete the pickup picker and any pickup-name rendering. TheisAuthorizedPickupflags on referent links and guardians still exist on the people surfaces (admin-editable) — they just gate nothing here anymore. Stale pickup copy below has been rewritten; if you find a straggler, the banner wins.Updated 2026-08-10, fourth batch (office acks are justifications) — all additive. When the school settles a follow-up directly (a phone call, the front desk), that ack now records a real justification on the family's behalf, so the day appears in the family's justifications list like any submitted one. Every justification row (list, mutation responses, day blocks) gains
channel:APP= submitted in the app,PHONE/IN_PERSON= recorded by the school. Render office rows as settled with a "recorded by the school" marker —submittedByNameis a staff name on them, so the marker ischannel, never the name — and expectcanEdit: falseon them always. Details in §5.2 and §5.5.Updated 2026-08-10, second batch (harmonization) — the two lists stop making the FE do backend work. All additive on this surface:
to-justifynow takesstudentId+fromand each item carriessuggestedKind(§4.1); the justifications list now takeskindand every justification row carriesstudentName(§5.2). No field here moved or changed meaning — existing screens keep working. Two contract notes beyond this guide's routes: the published Swaggerenumfor family eventstatenow lists only values a family can actually receive (regenerating a codegen'd client makes dead branches likeCANCELLEDstop compiling — the runtime never sent them), and the office justification list (GET /attendance/justifications, not a family route) no longer returnscanEdit— the one breaking change in this batch. It was a hard-wiredfalse(the office has no edit route); any office screen reading it should simply stop.This guide is self-contained: every request/response shape, state table and error row the family attendance loop needs is inline — you should not need another file to build it. The deep module contracts it distils are the attendance module guide (§5.8–§5.9) and the notifications guide; if a shape here ever disagrees with one of them, the module guide wins — and tell the backend, that's a doc bug. The wider referent persona (login, profile, children switcher) is composed in the referent-surface guide.
Scope: the family side of the attendance register — a referent (or the student themselves) following attendance, answering the school's justify-requests, and the notification center + email that close the loop. The school-side surfaces (admin board, teacher day, follow-up worklist) are not here; they live in the attendance module guide.
1. Mental model¶
The loop, end to end. The school records an absence or a late arrival → ~15 minutes later it becomes an event asking the family for an answer → the referent gets an inbox notification + a templated email → they open the child's day, submit a justification → the event resolves instantly, on every surface at once (family app and office worklist alike). Announcing an absence in advance short-circuits the whole loop: nothing is ever asked for that day.
The 15-minute rule governs everything here. An absence/late arrival becomes a family-visible event about 15 minutes after the register cell behind it is recorded (the correction grace window — env-tunable per environment, so don't hardcode the number). A correction made inside that window means the event never appears at all. Do not promise real-time. The register content itself (the day view's slots and cells) is not delayed — only the event objects are.
Two personas, one surface.
| Session | Reads (§4) | Submits (§5) | Notifications (§6) |
|---|---|---|---|
referent |
✅ linked students only | ✅ where the link has canWrite |
✅ (the loop's audience) |
student |
✅ self only | 403 — no self-justification | inbox exists, but attendance never writes into it |
| every other role (admin included) | 403 on all /attendance/family/* routes |
403 | inbox exists, empty for now |
Record narrowing is backend-owned. A referent sees only their linked
students; a student only themselves. There is no client-side "which children
may I see" filtering to do — and no way to probe: a foreign or made-up
studentId answers a hidden 404, never a 403 that would confirm
existence.
canWrite is per link, not per referent. A read-only link
(school-configured) sees everything in §4 but cannot submit justifications.
Fetch the link flags once from GET /auth/profile →
profiles.referent.students.items[] (each item: studentId, canWrite,
relationshipType, pickup/emergency flags) and hide the justify affordances
per child. Children's names: since 2026-08-10 both list endpoints carry
studentName on every row, so a name join is only needed where you list
children who have no rows — there, GET /students (record-narrowed to the
linked students) supplies them, joined on studentId. Full recipe:
referent-surface guide §2–§3.
Permissions drive the UI, not the role name. The justify form exists iff
the attendance.justify action is granted in GET /permissions (and in the
map a view-switch returns). Never hardcode "referents can submit".
2. Conventions on every call¶
- Base path: every path below is relative to
/api/v1— the wire path ofGET /attendance/family/to-justifyis/api/v1/attendance/family/to-justify. All routes require authentication (the platform's standard login/session contract — see the session-view guide); none is public. - Dates are calendar days in the school's timezone. Every
date— path param or body field — is exactlyYYYY-MM-DD. A full ISO instant is rejected400 VALIDATION_FAILED, not truncated. Never derive the day through the browser clock: the school's own timezone is the register's clock. - Pagination is the platform standard:
page/limitquery params,{ data: [...], meta: { total, page, limit, totalPages } }envelope. - The error body is the platform envelope:
{
"statusCode": 422,
"code": "JUSTIFICATION_NOT_EDITABLE",
"messages": {
"en_US": "This communication can no longer be changed — it covers 2026-09-10, which has already begun",
"it_IT": "Questa comunicazione non può più essere modificata — riguarda il 2026-09-10, giorno già iniziato"
},
"field": "…", // only on field-locatable validation errors
"timestamp": "2026-09-10T09:12:33.120Z",
"path": "/attendance/family/justifications/…"
}
Branch on code, render messages[lang] — the copy is backend-owned
and already localized. There is no params on the wire: params referenced
by the sentence are interpolated into messages and dropped, the rest
never leave the server.
3. The endpoints¶
Ten attendance routes plus the notification center. "R" = referent, "S" = student.
| # | Verb | Path | Purpose | Who |
|---|---|---|---|---|
| 1 | GET |
/attendance/family/to-justify |
Unresolved events across all linked children — the landing widget; filters studentId, from (§4.1) |
R + S |
| 2 | GET |
/attendance/family/students/:studentId/overview |
One child's year: stats + event history (§4.2) | R + S |
| 3 | GET |
/attendance/family/students/:studentId/days/:date |
One child-day in full (§4.3) | R + S |
| 4 | POST |
/attendance/family/justifications |
Submit a justification / announcement (§5.1) | R (canWrite) |
| 5 | GET |
/attendance/family/justifications |
The caller's submissions this year; filters studentId, from, kind (§5.2) |
R + S |
| 6 | PATCH |
/attendance/family/justifications/:id |
Revise one that has not started yet (§5.5) | R (canWrite) |
| 7 | DELETE |
/attendance/family/justifications/:id |
Withdraw one that has not started yet (§5.5) | R (canWrite) |
| 8 | POST |
/attendance/family/justifications/:id/files |
Attach one evidence document (§5.3) | R (canWrite) |
| 9 | DELETE |
/attendance/family/justifications/:id/files/:fileId |
Remove one document — any date (§5.3) | R (canWrite) |
| 10 | GET |
/attendance/family/justifications/:id/files/:fileId |
Signed download URL (§5.3) | R + S |
| — | GET |
/notifications (+ unread-count, read, read-all) |
The notification center (§6) | any logged-in user |
RBAC, for completeness: the three reads and the two justification-GETs ride
attendance.register read; every write additionally needs the
attendance.justify action (referent-only today) — which is why a
student session, holding the same read, still 403s on 4, 6, 7, 8 and 9.
There is no separate "may withdraw" grant: revising and withdrawing are the
same authority as submitting.
4. Following attendance — the reads¶
4.1 GET /attendance/family/to-justify?page=&limit=&studentId=&from= — the landing widget¶
Every event still awaiting the family's answer, across all the caller's
linked students, newest day first, paginated (default limit=20). One call
powers both the badge (meta.total) and the list. Resolved events leave the
list; history stays on the overview. A student session gets their own rows —
but the student-facing UI is the overview + day view; this list is the
referent's landing widget.
Two filters (2026-08-10): studentId narrows to one child; from keeps
events dated on/after a day. An unlinked or unknown studentId answers an
empty page, never an error — the same existence-hiding as everywhere on
this surface.
{
"data": [
{
"eventId": "…",
"date": "2026-09-07", // the school day it is about
"eventType": "ABSENT", // ABSENT | LATE_ENTRY
"suggestedKind": "ABSENCE", // the justification kind that covers it
"state": "UNDER_REVIEW", // see the state table in §4.3
"studentId": "…",
"studentName": "Ada Lovelace",
"surfacedByName": "Anna Rossi" // who recorded it; null if unresolvable
}
],
"meta": { "total": 1, "page": 1, "limit": 20, "totalPages": 1 }
}
suggestedKind (2026-08-10) publishes the coverage mapping — ABSENT →
ABSENCE, LATE_ENTRY → LATE_ENTRY — so you never translate between the two
enums yourself: pre-fill the submit form (§5.1) with it verbatim.
4.2 GET /attendance/family/students/:studentId/overview — one child's year¶
studentId, firstName, lastName, the stats block, and events[] — the
family-visible event history, newest first, same shape as §4.3's
dayEvents[].
"stats": {
"schoolDays": 41, // days with a register entry, minus the excluded ones
"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 // present share of recorded time
},
"minPresentHoursPercentage": 75 // the school's threshold, or null
}
Null semantics to render for, not around:
- every
percentageisnulluntil there is anything to count (start of year) — hide the number, don't show0%; minPresentHoursPercentageisnullwhen the school configured no threshold — hide the comparison entirely.
The counts are register-derived: a day nobody took does not count as a
school day yet, so early-year numbers grow as registers land. The hours pair
measures recorded time (the cells' own clock spans) — that is the number
the school's threshold speaks about; render presentHours.percentage against
minPresentHoursPercentage.
4.3 GET /attendance/family/students/:studentId/days/:date — one day in full¶
The child's day exactly as the school sees it. 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"], // notTaken | suspended | trip | absent | lateEntry | earlyExit | present
// usually one, can combine (late + left early);
// never empty — an untaken register is ["notTaken"]
"items": [ /* the day's slots, period order — below */ ],
"cells": [ /* the register as written, oldest first — below */ ],
"dayEvents": [ /* the family event shape — below */ ],
"justifications": [ /* every submission covering this day, newest first — §5.2 */ ]
}
items[] — the day's grid¶
One entry per slot of the child's own timetable that day:
{
"kind": "LESSON", // or "ACTIVITY"
"label": "Mathematics", // subject name, or activity name
"wallStart": "09:00", "wallEnd": "10:00",
"roomName": "Room 12",
"teacherNames": ["R. Bianchi"],
"source": "RECORDED", // "RECORDED" | "PROJECTED" | null
"record": { "status": "LATE_ENTRY", "time": "09:15", "note": null, … }
}
source is the slot's display state, and the three values are not
interchangeable:
source |
Meaning |
|---|---|
"RECORDED" |
A stored register cell — record carries status/time/note |
"PROJECTED" |
Inferred from an earlier cell (DAILY-mode departments take the register once per day and project it across the slots) — only record.status is meaningful |
null |
Not taken. This is not an absence — render it as "no register", never as A |
cells[] — the register as written¶
{ recordId, status, time, note, wallStart, label, recordedByName,
recordedAt, … }, oldest first. A cell survives timetable edits (each entry
is named from its own frozen snapshot), so a cell can legitimately exist at a
time items[] no longer schedules. Render cells[] as the source of truth
for "what the school wrote" and items[] for "the day's grid" — you
cannot reconstruct one from the other.
dayEvents[] — the family event shape¶
{
"eventId": "…",
"date": "2026-09-07",
"eventType": "ABSENT", // ABSENT | LATE_ENTRY
"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 — null while unresolved
"surfacedByName": "Anna Rossi" // who recorded it; null if unresolvable
}
States you can receive, and how to render them:
state |
Meaning | Render |
|---|---|---|
UNDER_REVIEW |
Armed, awaiting the family's answer | "please justify" |
NOTIFIED |
The grace expired unanswered — inbox + email went out | same as UNDER_REVIEW — still open |
FOLLOW_UP |
Reminded once (at most once per event), still unresolved | same as UNDER_REVIEW — still open |
ACKNOWLEDGED |
Resolved — ackChannel: "APP" = one of the family's own justifications did it; PHONE / IN_PERSON = 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) and no justificationId — to show which submission covers a
day, render the day's justifications[] block alongside (they match by kind
and date, §5).
4.4 Errors on the reads¶
| Case | Answer |
|---|---|
studentId unlinked, someone else's, or nonexistent |
404 NOT_FOUND — indistinguishable on purpose |
malformed :date (not YYYY-MM-DD, or an impossible day) |
400 VALIDATION_FAILED |
office/teacher session on any /family route |
403 |
5. Answering the school — justifications¶
The family's one write path — and it does two different jobs depending on which side of today the dates fall. Same endpoint, same form; different rules and a different effect.
| The form is about… | Always available? | Effect on the register |
|---|---|---|
| today or a day ahead — announcing | yes, unconditionally | the school's register for those days is pre-filled from the announcement |
| a past day — answering | only where the school has recorded an absence to answer | none — an answer never changes a cell |
Announcing is the family telling the school something it does not know yet.
Answering is the family responding to something the school has already
recorded. Build the form so the user never has to know the distinction: the
only place it surfaces is one refusal (422 JUSTIFICATION_DAY_NOT_FLAGGED,
§5.4a) and one detail on the confirmation copy (§5.4a again).
The effects are immediate and automatic:
- every matching open event flips to
ACKNOWLEDGED/ackChannel: "APP"on the spot — it leaves the to-justify list and the office worklist in the same instant; - a day announced in advance never produces anything to chase: the
register for that day is pre-filled from the announcement there and then, and
the day's follow-up is born already resolved (
NOT_REQUIRED); - the submission appears in the
justifications[]block of every student-day surface (family day view and school register row alike) from the moment it lands.
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 + suggestedKind (§4.1) and submit. Coverage rules:
Submitted kind |
Covers |
|---|---|
ABSENCE |
ABSENT events only |
LATE_ENTRY |
LATE_ENTRY events only |
EARLY_EXIT |
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 to startDate) |
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": "…", "studentId": "…",
"studentName": "Ada Lovelace", // 2026-08-10 — on every justification row
"kind": "ABSENCE",
"startDate": "2026-09-10", "endDate": "2026-09-12",
"time": null, "reason": "influenza",
"attachments": [], // { fileId, fileName }[]
"submittedByName": "Maria Rossi", // snapshotted at submit
"submittedAt": "2026-09-09T18:00:00.000Z",
"canEdit": true // §5.5 — recomputed on every response
}
Who may submit: a referent whose link to the student has canWrite. A
read-only link is 403; an unlinked student is a hidden 404; a student
session cannot submit at all (403).
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.
5.2 GET /attendance/family/justifications?page=&limit=&studentId=&from=&kind=¶
The caller's submissions this school year, newest first, paginated;
studentId narrows to one child and kind (2026-08-10) to one kind
(ABSENCE / LATE_ENTRY / EARLY_EXIT). Students see their own. Rows are
the same JustificationDto, attachments included, plus canEdit (§5.5) and
— since 2026-08-10 — studentName, so a multi-child table needs no name
join.
Every row carries channel (2026-08-10): who recorded the statement.
APP = submitted here, in the app. PHONE / IN_PERSON = the school
settled the day directly with the family and the ack recorded the
justification on their behalf — so office-settled days now appear on this
list too, as ordinary rows. What to build for them:
- render a "recorded by the school" marker off
channel—submittedByNameis a staff name on these rows, so the marker is the discriminator, never the name; canEditis alwaysfalseon them, whatever their dates: the office's statement is not the family's to rewrite or withdraw (PATCH/DELETEanswer422 JUSTIFICATION_NOT_EDITABLE);- documents still attach (§5.3 — the
canWritegate, unchanged): a family can drop the certificate onto the school's record of the phone call; - they conflict like any other row: a new family
ABSENCEoverlapping an office-settled day is a409 JUSTIFICATION_CONFLICT, exactly as against the family's own earlier statement. A same-day same-kindLATE_ENTRY/EARLY_EXITresubmission replaces the office row in place — sameid, new content, and the marker flips toAPP: the statement is now the family's.
from filters by coverage, not by start. from=2026-09-14 keeps every
communication whose endDate is on or after that day — so a multi-day
absence that began on the 12th and runs to the 16th is included. That is the
useful reading for a family: an absence already under way is still upcoming.
The two lists a landing screen wants, neither needing a date from the user:
- "What does the school still want an answer about" →
to-justify(§4.1), unchanged. - "What have we already told the school about, from today on" →
justifications?from=<today>.
They are disjoint by construction: anything covered by a submission has already been resolved (or never needed an answer).
One ordering trap: the list is sorted by submission time, not by the days
it covers. A justification submitted months ago can still cover next week, so
do not assume the upcoming ones sit on page one — page through the result (or
pass a generous limit; per-family volumes are tens per year, not thousands)
and then filter.
Refetch both lists after any submit, revise or withdraw. And do not cache
rows by id across submissions: re-submitting the same kind on the same day
overwrites the existing row in place (same id, new content), and a new
ABSENCE hard-deletes any late-arrival/early-exit rows inside its range.
The same content also appears per-day in the day view's justifications[]
(§4.3): every submission whose date range covers that day, in the identical
block the school sees on its register row (the DTO minus studentId /
studentName / canEdit — the day already names the student, and the
school's copy carries no edit affordance).
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, field namefile, one file per call, up to 5 per justification, max 10 MB,application/pdf/image/jpeg/image/png. Same writer gate as the POST.DELETE /attendance/family/justifications/:id/files/:fileId—204. AfileIdbelonging to a different justification is a plain404.GET /attendance/family/justifications/:id/files/:fileId— mints a signed URL{ url, fileName, mimeType, byteSize, expiresAt }; fetch the URL directly, it expires — don't cache it.
Documents ignore the edit window. Both the upload and the delete work on
any justification regardless of its dates, including ones that are already
final. This is deliberate and it is the one exception to "the past is
read-only": a certificate normally arrives after the child is back, so the
communication it belongs to has almost always started already. Do not gate
the attachment controls on canEdit — the statement is frozen, its evidence
is not.
(The school downloads the same files through its own route, outside
/family — not this guide's surface.)
5.4 Errors on the write path¶
| Case | Answer |
|---|---|
| kind-shape mismatch, out-of-year range | 400 VALIDATION_FAILED |
kind or studentId in a PATCH body |
400 (not in the DTO — §5.5) |
read-only link (canWrite false) |
403 |
| student session on any write | 403 |
unlinked studentId in the body |
404 NOT_FOUND |
| justification not the caller's / nonexistent | 404 JUSTIFICATION_NOT_FOUND |
the communication has already started (PATCH/DELETE) |
422 JUSTIFICATION_NOT_EDITABLE |
a PATCH that would back-date into a started day |
422 JUSTIFICATION_NOT_EDITABLE |
an office-recorded row (channel ≠ APP) on PATCH/DELETE, whatever its dates |
422 JUSTIFICATION_NOT_EDITABLE |
| the new range collides with another communication | 409 JUSTIFICATION_CONFLICT |
| a past day the school never recorded an absence for | 422 JUSTIFICATION_DAY_NOT_FLAGGED (§5.4a) |
a past EARLY_EXIT, whatever the day |
422 JUSTIFICATION_DAY_NOT_FLAGGED (§5.4a) |
file on an EARLY_EXIT |
422 JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED |
| 6th file | 409 JUSTIFICATION_ATTACHMENT_LIMIT |
| oversized / wrong-type file | standard file-upload errors (413 / 422) |
5.4a Reaching into the past — what the form may and may not do¶
Today and every day ahead: the form is always open. Nothing to check, no date bound to enforce client-side.
A past day is only accepted where the school has actually recorded
something to answer — in practice, exactly the days already sitting on
to justify (§4.1) and the resolved days in the child's history. Anything
else answers 422 JUSTIFICATION_DAY_NOT_FLAGGED. Render the server
messages verbatim: they name the day and the thing that is still
available ("You can still tell us about today or a day ahead"), so the refusal
is not a dead end.
Two details worth building for:
- There is no deadline. A day on to justify stays answerable for as long as the year is open. Do not build a countdown, grey out old rows, or hide the answer control by age — a certificate arrives when it arrives, and the school controls which days are on the list.
EARLY_EXITis today-or-future only. An early exit is inherently an announcement; there is no such thing as answering one after the fact. Offer the kind only for today onward.
A range that starts in the past and ends in the future is judged on its past part alone — future days do not rescue it. One flagged day anywhere in that past part is enough, so a Mon–Fri illness is accepted even if the school only took the register Mon–Wed.
5.5 Revising and withdrawing¶
The rule, in one line: a family may change what it has announced, never what has already happened. A communication stays editable only while the first day it covers is still in the future. The moment that day begins the whole row freezes — including a multi-day absence that started yesterday and runs through next week. There is no partial editing of the future tail.
canEdit on every row is the answer — use it, don't recompute it. It is
true only when the window is open and the caller holds a writable link to
that child. It is false for every student session and on every row the
school sees.
canEditcannot be derived from/permissions. Theattendance.justifygrant is unconditional — a referent always has it. The window is a per-row temporal fact, not a permission, so/permissionswill happily report that the user may justify while a specific row is frozen. Drive the edit and withdraw controls offcanEdit, and nothing else.
PATCH /attendance/family/justifications/:id — send only what changes:
startDate, endDate, time, reason. Anything omitted keeps its current
value. Answers the full refreshed JustificationDto.
kindandstudentIdare not accepted and a body containing either is a400. Changing the kind of communication, or moving it to a sibling, is withdraw-and-resubmit.- For
LATE_ENTRY/EARLY_EXIT, sendingstartDatealone moves the whole (single-day) communication — you do not also needendDate. - For
ABSENCE, if the newstartDateis later than the currentendDate, send both or you get a400. - The result must satisfy the same rules a fresh submission would (kind shape, inside the school year).
One behaviour differs from submitting. Where re-submitting the same kind
on the same day silently replaces the existing communication, moving one
onto a day that already has the same kind is a 409 JUSTIFICATION_CONFLICT
instead — an edit already has a target row, so nothing is destroyed as a side
effect. The family withdraws the other one first. Everything else about the
conflict rules is identical, including an absence widened over a late arrival
or early exit, which still absorbs it.
DELETE /attendance/family/justifications/:id — 204, same window.
Worth a confirmation dialog, because it is not a soft hide:
- the register entries the announcement pre-filled are removed too — this is the biggest consequence and the one worth naming in the dialog, because it is much more than "the row disappears from my list". Any entry a teacher has since edited is kept: their correction is their own statement about the day and outlives the announcement;
- a day whose register was written by the school goes back to needing an answer and will reappear in to justify after the usual correction grace (~15 min), so the school will chase it again. A day whose entries came from the announcement itself simply goes quiet — the school never asserted an absence there, so there is nothing left to answer;
- its attachments go with it.
Withdrawing and immediately re-submitting inside that grace is silent — the family will not trigger a notification by fixing a mistake straight away.
Note the interaction with the window: a communication announced for today
freezes the moment the day begins (canEdit: false), so from then on its
pre-filled entries can only be corrected by the office. Say so in the
confirmation copy if the user is announcing for today.
One thing withdrawal never touches: if the school had already resolved the day
themselves — a phone call to the office, a conversation at the desk — that
resolution stands. It was the school's decision, not the family's statement.
Since 2026-08-10 that decision is itself visible as a row on the list
(channel: PHONE | IN_PERSON, §5.2) — and it is exactly the row the family
cannot withdraw.
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.
| Verb + path | Purpose | Answer |
|---|---|---|
GET /notifications |
The inbox, newest first | paginated { data, meta } (standard page/limit) |
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 or is planned) | { 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": "attendance.event",
"payload": { /* kind-specific — below */ },
"createdAt": "2026-09-10T08:20:00.000Z",
"readAt": null // null = unread
}
No tenantId, no 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 (same division of labor as the error contract), and render unknown
kinds harmlessly (a generic "you have a notification" row, never a crash) —
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 | Suggested 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: {studentName}'s {absence/late entry} on {date} is still awaiting a justification" |
Two rules the copy and navigation must respect:
- Copy MUST lead with the
date. Events can be backdated (the school filling in last Tuesday), so "absent today" is wrong copy — this is a justify-request about a specific day, not a live alert. - Deep-link both kinds to the child's day view for
studentId+date(§4.3), where the justify form pre-fills (§5.1).
Two badges, two meanings — keep both. The to-justify meta.total counts
items needing action (drops when an event resolves, 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.
Audience today: referents with accounts, one notification per linked referent per event. Students are never notified about their own events. An event resolved (justified or office-acked) before its grace expires never notifies at all. Each notification also sends a templated email in the school's language — backend-owned end to end, nothing for the FE to build.
7. Suggested screen map¶
- Landing — the to-justify list (§4.1) as the primary widget with
meta.totalas its badge; the notification bell pollingunread-count(§6). - Child → attendance overview (§4.2) — stats header (percentages vs threshold, only when non-null) + event history; tapping an event or a calendar day opens…
- Child → day view (§4.3) — the slots grid (
items[]), the recorded cells (cells[]), the day's events with their states, the day'sjustifications[]; a "justify" button (iff the link hascanWriteandattendance.justifyis granted) opening… - Justify form (§5.1) — kind selector driving the conditional fields; pre-filled from a to-justify item or a notification deep link; attachment upload afterwards (§5.3).
- My submissions (§5.2) — history, per child, attachments downloadable
and removable. Two sensible tabs off one endpoint: upcoming
(
?from=<today>) and all. Show edit/withdraw controls only where the row'scanEditis true (§5.5) — never derived from/permissions— and reuse the §5.1 form for the edit, minus the kind and child selectors, which are fixed. Attachment controls are not gated oncanEdit. → For the richer variant — one modal listing every matter, pending and justified together, filterable and sorted future-first — screens 1 and 5 collapse into the justification table guide, which builds it from these same two endpoints with no backend work. - Notification center (§6) — list, mark-read on tap (fire-and-forget — idempotent and cheap), "mark all read" then re-poll the count; rows deep-link to screen 3.
8. What is NOT here¶
- No real-time. Events surface ~15 minutes after recording; the badge is a poll. No sockets, and no Web Push / mobile push yet (both are designed as future channels of the same notifications — do not build against them).
- No school-side approval step to track — a submission takes effect immediately and nobody reviews it. (Editing and withdrawing do exist now, but only before the first covered day begins — §5.5.)
- No editing the past. Once a covered day has begun the communication is final, and there is no appeal route; a genuine mistake on a past day is a phone call to the office. The only thing that stays mutable is its attachments.
- No self-justification by students — a student session reads §4 for themselves but cannot submit.
- No school-side surfaces — the admin board, the teacher day, the office follow-up worklist and the school-side attachment download are the attendance module guide's territory.
Student lifecycle (2026-09-03)¶
GET /attendance/family/to-justify and the family justification list return ENROLLED children only, so a child who has left drops out of the badge and the lists. GET /attendance/family/students/:id/day/:date, /overview and justification-by-id still answer for that child, so route exited children to history-only views using the status you already read from /students. Notifications and reminders about an exited or pre-enrolled child stop; the events stay open server-side. No payload change. Spec: docs/superpowers/specs/2026-09-03-student-participation-status-fences-design.md.