Skip to content

Family attendance surface — harmonization and service split

1. Problem distillation

  • Writing the justification-table FE guide forced a frontend to do backend-shaped work: normalize two enums that name the same idea, join a student display name the surface already knows, and re-derive which justification kind answers which event. Each of those is a backend asymmetry with a frontend paying for it.
  • A follow-up audit of the whole family surface found the ergonomic asymmetries sit alongside two latent defects, both introduced by the CRUD iteration a day earlier: canEdit is computed by two different rules depending on which endpoint answers, and the provenance re-stamp runs on opposite sides of the covering sweep in create versus update. Neither is reachable today (§4 says exactly why, and what would make them reachable) — which is the argument for fixing them now, while the code is a day old, rather than after a policy change turns them into an incident.
  • attendance-justifications.service.ts reached 1217 lines doing three unrelated jobs (mutations, read projections, attachments), with the create and update paths carrying ten near-verbatim duplicated blocks between them — the covering sweep and the supersede branch among them.
  • The two family lists are correct and disjoint by construction. Nothing here is a rewrite: this is paying down the debt one feature iteration created, plus the asymmetries it made visible.

Success criteria (observable behavior that proves this works): - canEdit for a given row is the same value whether it arrives from POST, PATCH or the list — for every caller, including one holding attendance.justify without the referent role. - A frontend rendering the family justification list shows child names with no second call and no client-side join. - A frontend can ask "what is open for this child, from this date" without paging the whole cross-child feed. - A frontend never hard-codes the ABSENTABSENCE mapping: the server states which kind answers a pending event. - The covering sweep, the supersede branch and the post-commit blob drain each exist once in the service layer, and create/update/remove are readable end-to-end without scrolling past a duplicate. - No published response field is a constant, and no published enum advertises members the endpoint cannot return.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No unified /matters endpoint. Ruled out by chapter 5 §7.2 — see §7. The two lists stay two routes. - No ordering change. The family list keeps createdAt desc; the trap is documented rather than fixed (§8). - No fix for the office-settled gap. A day resolved by phone stays out of both family lists (§8). - No module foldering. src/attendance/ is 33 files and five controllers against chapter 5's fold-at-two threshold; that debt predates this work and is deferred (§9). - No enum rename. AttendanceEventType.ABSENT and AttendanceJustificationKind.ABSENCE keep their names; the wire gains the mapping instead (§5). - No schema change and no reseed. If implementation discovers either is needed, that contradicts this spec — stop and re-open it.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
docs/05-crud-patterns.md §7.2 "Don't fold distinct surfaces into one route" The governing rule for the biggest question here, verbatim: one route = one authorization gate × one @AppliesPolicy × one row set, and "if FE wants one network round-trip per screen, that's response-composition on the client, not a backend route merge." The two family lists share a gate and a policy but have different row sets, so they stay separate. Nothing — this is a direct ruling, not an analogy. It is why §1's first non-goal exists.
docs/05-crud-patterns.md §1 "Foldering large modules" The threshold and the rule for splitting an oversized module. Applied here at the file level rather than the folder level: the service splits along sub-resource seams without moving anything. The chapter's remedy is folder moves at ≥2 controllers / ~15 files. src/attendance/ qualifies twice over, but foldering it is a 33-file diff landing on uncommitted work — deferred in §9, and the service split is the on-axis subset.
src/attendance/attendance-insights.service.ts + dto/admin-justification-list.dto.ts The office justification list already denormalizes studentName onto its row DTO and already composes filters with IntersectionType(FiltersDto, PaginationQueryDto). Both are the shape the family list should have had. Its date filter is point-in-range where the family's from is open-ended forward. Both are defensible; this spec keeps both rather than unifying them (§8).
src/common/utils/format-person-name.ts Self-described as "the single implementation of the person display-name join", already used at three call sites in this module. The new studentName projection uses it, and the one place that inlined the join is corrected. Fits cleanly.
src/students/dto/student-summary.dto.ts Chapter 5 §7.2 names it as the right place to DRY a student projection shared across surfaces. Cited as precedent that denormalizing a display name onto a list row is sanctioned practice, not a shortcut. The attendance DTOs carry a flat studentName string rather than a nested summary object, matching their own siblings (FamilyToJustifyItemDto, FollowUpItemDto, AdminJustificationRowDto). Introducing a nested shape on two of five DTOs would trade one asymmetry for another.
src/common/utils/assert-referent-can-write.ts The two-helper pattern — a throwing gate and a non-throwing resolver — and its deliberate null-vs-empty-set divergence. The divergence is exactly what the canEdit defect trips over (§4). The fix is a third, explicitly-named helper rather than bending either existing one.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Unchanged. Every touched query keeps its tenantId predicate; the new studentName projection rides the existing student relation, already tenant-fenced through familyStudentAccessWhere. No new data access path.
Academic-year scope yes Unchanged — both family lists keep their active-year pin via resolveActiveYear. No surface becomes year-spanning.
RBAC entity key existing ATTENDANCE No delta. Same entity, same routes.
Scopes existing attendance.register read No delta. New query params narrow an existing row set; they never widen it. A filter is not an authorization change (ch05 §7.2's "a view cannot change which rows the server returned" — these narrow, never widen).
Actions existing attendance.justify No delta. No new mutation.
Service base custom, split into three attendance-justifications.service.ts (mutations + the consistency matrix), attendance-justifications.read.service.ts (list, admin list, row blocks, canEdit), attendance-justification-files.service.ts (upload, delete, both signed-URL paths). All three stay providers of AttendanceModule; no folder moves, no barrel changes. The file does three jobs at 1217 lines. The seams are already visible in its own section comments.
queries.ts shape named functions, no repository class findFamilyJustifications gains kind + the student include; findFamilyOpenEvents gains studentId + from; suggestedKindFor joins coveringKindsFor / coveredEventTypesFor as the covering rule's third reading. No new query module. Module convention unchanged; the new mapper belongs beside its two siblings rather than inlined in a projection.
Error codes none new No new failure mode: every added filter narrows, and a filter matching nothing is an empty page, never an error (the existing existence-hiding rule). Adding a code for "no rows" would contradict the surface's own doctrine.
DTO conventions additive fields + one removal JustificationDto += studentName; FamilyToJustifyItemDto += suggestedKind; FamilyJustificationsQueryDto += kind; new FamilyToJustifyQueryDto; AdminJustificationRowDto drops the inherited canEdit via OmitType. §5 details each.
File-backed sub-resources collection (existing) Moves to the new files service; behaviour identical. Pure relocation.
Custom fields no n/a — no entity here carries them.
Profile completeness no n/a — no delta to completion-required-fields.ts.

4. Data model plan

Schema deltas

  • None. No tables, columns, enums, FKs or indexes change.

Migration shape

  • Additive / destructive / renaming: no migration.
  • Data backfill: none.
  • Hazards from chapter 12 checklist: none — chapter 12 is not engaged.

The studentName projection reads the existing Student relation the admin query already joins. The new filters are served by existing indexes: to-justify's studentId narrowing composes under the same student: predicate the policy already applies, and its from rides AttendanceDayEvent's existing date filtering.

The two defects, precisely

Both were introduced by the CRUD iteration. Neither is reachable today — stated plainly because it sets the priority: these are latent hazards to fix while the code is fresh, not incidents.

Defect 1 — canEdit has two definitions. The list computes isMutable(row) && writable.has(studentId); create and update compute isMutable(row) alone. They disagree whenever writable is empty for a caller who nonetheless passed the write gate, which is a real combination in the abstract: referentWritableStudentIds returns an empty set for any non-referent caller, while assertReferentCanWrite passes non-referent callers through.

It is unreachable today, and the reason is worth writing down because it is the thing that could change. AttendanceFamilyPolicy (attendance.policy.ts:118-126) declares branches for referent and student only and fails closed for everyone else, so no third role can reach these routes however its grants are edited — and the student preset holds no justify action, so a student never reaches the mutations where the definitions differ. The disagreement therefore requires both a new branch on that policy and a justify grant for the new role.

That is a plausible future change, not a fanciful one — a family-liaison staff role is exactly the shape of thing that gets added to a family surface. When it happens, the symptom is canEdit: true on a mutation response and canEdit: false for the same row on the very next list call, which is the kind of bug that gets attributed to caching for a day before anyone suspects the backend.

Defect 2 — the provenance re-stamp sits on opposite sides of the covering sweep. create re-stamps before the sweep; update re-stamps after it. The outcomes happen to agree today, because restampEventProvenance filters on justificationId: null and the sweep only ever produces non-null stamps, so the two operate on disjoint row sets in either order. It is a latent hazard rather than a live bug: the guard that makes the order irrelevant is three files away from the code that depends on it, and any future widening of either predicate turns an invisible ordering choice into a behavioural difference between two paths that are supposed to be the same rule.


5. API surface

No route is added or removed. One query DTO is new and one grows; four response DTOs change (three in shape, one in its published enum only).

Verb Path Change
GET /attendance/family/to-justify + studentId?, from? query params (new FamilyToJustifyQueryDto); response items + suggestedKind
GET /attendance/family/justifications + kind? query param; response items + studentName
POST / PATCH /attendance/family/justifications[/:id] response + studentName; canEdit now computed by the shared rule
GET /attendance/justifications (office) response canEdit (was a hard-wired false)

studentName on JustificationDto

The single highest-value fix. Three of five family DTOs describing a student already carry a name; the justification DTOs carry none, so a multi-child family table must call GET /students and join on studentId — work the office list is spared, because its query already includes the relation forty lines below the family one that does not.

Added as a flat joined string through formatPersonName, matching its two nearest siblings (FamilyToJustifyItemDto.studentName, AdminJustificationRowDto.studentName) rather than the split firstName/lastName the overview and day DTOs use. Those two keep their split shape: they are single-student responses where the name is envelope-level context, and changing them would break shipped contracts for no gain. The residual three-shape inconsistency is recorded in §9.

RowJustificationDto — the block nested on day views and register rows — deliberately does not gain it: its enclosing response already names the student, and adding a redundant name to every row of every office register would be pure payload.

suggestedKind on FamilyToJustifyItemDto

/**
 * Which kind of communication answers this event — submit one of these to
 * resolve it. ABSENT is answered by an ABSENCE, a late arrival by either.
 */
@ApiProperty({ enum: AttendanceJustificationKind })
suggestedKind: AttendanceJustificationKind;

The ABSENTABSENCE mapping is real domain logic (coveringKindsFor / coveredEventTypesFor) that lives server-side and has never been exposed. Today every frontend re-derives it by hand, and the FE guide has to teach it. Publishing the answer costs one enum field and deletes a whole class of client-side guessing.

This is deliberately not an enum rename. ABSENT (what the register witnessed) and ABSENCE (what the family called it) are genuinely different concepts that happen to be adjacent; collapsing them would need a migration and would lose the distinction the covering rule is built on.

New filters

FamilyToJustifyQueryDto (new, extends PaginationQueryDto): studentId? (uuid) and from? (date-only, date >= from). The endpoint currently accepts no narrowing whatsoever while its admin analogue accepts nine filters and its own family sibling accepts two. A referent with three children cannot ask "what is open for Ada".

FamilyJustificationsQueryDto gains kind?, which the office list already has.

Both are additive and optional; omitting them reproduces today's behaviour exactly.

canEdit off the office DTO

AdminJustificationRowDto inherits canEdit and the service hard-wires it to false on every row. Worse, it inherits the published description — "Whether you can still change or withdraw this communication… once a covered day begins, the submission is final" — into the office-facing schema, where an office frontend will reasonably read it as date-derived and build a stale affordance on it.

Removed via OmitType(JustificationDto, ['canEdit'])and the projection must stop emitting it, which is a separate step and the one place this change can be got wrong. @AggregateResponse() routes are passed through the field-filter interceptor unchanged (field-filter.interceptor.ts:119-131), so the DTO is documentation and a type, not a runtime filter. Today the admin rows are built by spreading the family projection ({ ...this.toDto(r, …, false), studentName }), and a spread will happily carry canEdit onto the wire past an OmitType'd declaration. Changing only the DTO would produce the worst outcome available here: a schema that says the field is gone while the API still returns it.

The split therefore gives the read service two named projections — toFamilyDto (carries canEdit) and toAdminRow (does not) — rather than one projection the admin path spreads and patches. That is the right shape independently: the two surfaces answer different questions and have been sharing a mapper by accident of inheritance.

This is the one breaking change in the specChange classification below).

Swagger: published enums narrowed to the reachable set

FamilyToJustifyItemDto.state and FamilyDayEventDto.state both publish all seven AttendanceEventState members. The runtime never returns CANCELLED or SUPERSEDED to a family — the grace window is only real because families cannot observe it — and to-justify narrows further still, to NOTIFIED/FOLLOW_UP/armed UNDER_REVIEW.

Fixed with an explicit enum: array on each @ApiProperty listing only reachable members. No runtime behaviour changes and no field is removed, so no response can differ from today's — but this is not quite "nothing breaks": a consumer generating types from the OpenAPI document gets a narrower union, and a switch with a CANCELLED branch will stop compiling on regeneration. The values were already unreachable, so such a branch was always dead code; it is nonetheless a compile-time event for a typed client, not a silent no-op. Classified as contract-narrowing rather than additive in the table below.

Change classification

Stated explicitly because "breaking or additive" is the first question any consumer asks, and the answer is almost entirely additive, with exactly one exception.

Change Class Who notices
studentName on JustificationDto Additive Nobody, until they use it. Ignoring a new field is free.
suggestedKind on FamilyToJustifyItemDto Additive ditto
studentId? + from? on to-justify Additive Omitting them reproduces today's response byte for byte
kind? on the family justification list Additive ditto
canEdit removed from the office justification row BREAKING Any office client reading the field. It is currently always false, so a consumer branching on it has dead code — but row.canEdit becomes undefined, and if (!row.canEdit) keeps working while row.canEdit === false stops.
Published enum narrowed on the two family event DTOs Contract-narrowing No runtime response changes. A codegen'd client's union type narrows, so a branch on CANCELLED/SUPERSEDED stops compiling. Those branches were always unreachable.
canEdit unified to one rule No observable change Every caller who can reach create/update is a referent whose link has canWrite, so the new rule returns exactly what the old one did. The fix removes a disagreement that was never reachable (§4), it does not change a reachable value.
Re-stamp ordering normalized No observable change The two orders act on disjoint row sets (§4)
Three-way service split, duplication collapse, DTO-typed list signature Internal No wire change. Intra-module injection sites move; nothing outside AttendanceModule imports these services.

So: four additive wire changes, one breaking removal, one schema narrowing, and the rest internal. If the office canEdit removal is unwelcome, dropping it leaves the spec fully backward-compatible at runtime — at the cost of leaving a published field whose description contradicts its value.

Swagger considerations

  • The new query params need no error examples: a filter that matches nothing returns an empty page, per the surface's existing existence-hiding rule.
  • Controller JSDoc gains one sentence each for the new filters, in family language.
  • AdminJustificationRowDto's OmitType must keep its studentName addition — verify the composed class still declares it.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none — every touched route is @AggregateResponse()
Role grants (roles.ts) none
*_SCOPES runtime constant none

No reseed. Filters narrow an already-authorized row set; they are not an authorization surface.

One durable note for whoever revisits canEdit: its correctness now depends on the referent role check inside referentWritableStudentIds agreeing with the write gate's admission rule. §4's Defect 1 is exactly what happens when they disagree, and the fix (§7) makes the dependency explicit instead of incidental.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Chapter 5 §7.2 — one route per gate × policy × row set We do not diverge — recorded here because the obvious "fix" for the FE's two-call merge is a unified /matters endpoint, and that is what §7.2 forbids. The two lists share a gate and a policy but have different row sets. The FE wanting one round-trip per screen is explicitly named in the chapter as client-side composition, not a route merge. The frontend keeps merging two lists. Mitigated by every other fix here, which is what makes the merge cheap.
referentLinkCanWrite returns null for non-referents ("gate N/A"); referentWritableStudentIds returns an empty set ("no affordance") One helper — canEditJustification(row, schoolToday, writableStudentIds) — becomes the single definition of the flag, and the mutation paths resolve writableStudentIds through the same referentWritableStudentIds the list uses, for the one student they touch. Neither existing helper changes. The two conventions are each right for their own caller and were documented as deliberately different. The bug was not the divergence; it was that three call sites re-implemented the combination from two helpers with opposite conventions, and two of the three got it wrong. create and update pay one extra indexed StudentReferentLink lookup they could have inferred from the write gate they already passed. Deliberate: inferring it is precisely the shortcut that produced two definitions, and a single cheap query on a human-cadence write path is a good price for the flag meaning one thing.
A response field may be a documented constant (canEdit: false on the office list) Removing it from the office DTO entirely A constant field with an inherited description that contradicts its value is worse than an absent one — it invites an office frontend to build a date-derived affordance that will never fire. A schema field disappears from an office endpoint. Low risk: the office justification list shipped 2026-08-07 and no FE guide documents canEdit on it. Flagged for coordination in §11 regardless.
The published @ApiProperty({ enum }) mirrors the Prisma enum Publishing only the members the endpoint can actually return The schema is the contract. Advertising CANCELLED to a surface architecturally incapable of returning it is a false contract, and the grace window depends on families never seeing those states. The DTO's enum list and the Prisma enum diverge textually, so a future member addition needs a conscious decision about family visibility. That is the point.
One service per resource Three services for one resource 1217 lines doing mutations, read projections and attachments. The split follows seams the file already marks with its own section comments; DI, module, routes and RBAC are untouched. Three files to open instead of one. Bought: each is holdable in context, and the create/update duplication collapses into shared private helpers rather than being copied a fourth time.

8. Pushback log

Position Conflicts with Proposed instead Status
"Fix all discrepancies" (chat) Three of them cost more than they return: the list's createdAt ordering needs a new index and therefore a migration; the office-settled gap needs a third row kind for an unmeasured case; module foldering is a 33-file diff on uncommitted work. Fix the ones that are defects or that make a frontend do backend work. Document the ordering trap, keep the gap out, defer the foldering. All three confirmed in chat. Resolved — chat 2026-08-10
The FE's natural ask: one endpoint for the merged table Chapter 5 §7.2 (different row sets ⇒ different routes) Keep two routes; make merging cheap by fixing the asymmetries that make it expensive. The FE guide already documents the merge and it is what the chapter prescribes. Resolved — doctrine, not preference
ABSENT vs ABSENCE should be one enum A rename is a migration plus an FE-breaking change across every DTO carrying either, and the two words genuinely name different things (what was witnessed vs what was claimed). Publish the mapping as suggestedKind instead of collapsing the concepts. Resolved
canEdit: false on the office list is "accurate, not a placeholder" — asserted in a code comment written during the CRUD iteration The inherited Swagger description says the opposite of what the value does, and it ships in the office-facing schema. Remove the field from the office DTO. The comment was right that false is not a lie about permission; it was wrong that a constant belongs in a published contract. Resolved — supersedes that comment, which is deleted

9. Deferrals

  • Module folderingsrc/attendance/ is 33 files and five controllers against chapter 5's fold-at-two-controllers / ~15-files threshold. Deferred: a 33-file move landing on an uncommitted feature branch, off-axis for these discrepancies. Follow-up: schedule as its own mechanical change once the CRUD work is committed.
  • The three student-name shapes — joined studentName (to-justify, justifications after this spec), split firstName/lastName (overview, day), none (RowJustificationDto). Only the third is fixed here; unifying the other two is a breaking change to shipped single-student responses with no consumer asking for it. Follow-up: revisit if a third multi-child surface appears.
  • RowJustificationDto vs JustificationDto — the day view returns the same rows in a lossy, differently-shaped DTO, so a frontend cannot reuse one renderer or one type. Deferred: converging them changes either an office register payload or a family day payload, both shipped. Follow-up: next attendance FE batch, if the duplication actually bites.
  • date vs from filter semantics — the office list filters point-in-range, the family list open-ended-forward. Both defensible, neither wrong; unifying them would break one. Follow-up: only if a caller needs the other semantics.
  • The three unpaginated family aggregatesoverview returns an entire year of events uncapped. Not a problem at family scale. Follow-up: revisit if a school with year-round daily events appears.
  • AggregateResponseDto inheritance is inconsistent across the paginated item DTOs — schema noise only, no runtime effect. Follow-up: fold into whichever iteration next touches those DTOs.
  • The formatPersonName bypass and the duplicated isoDateOf — both corrected here as part of the split (they are inside the files being restructured), so they are not deferrals; recorded so a reviewer does not flag them as scope creep.

10. Open questions

None. The four scope decisions — service split only, ordering unchanged, office-settled gap unchanged, no route merge — were resolved in chat 2026-08-10; the remaining calls (name shape, enum handling, office canEdit removal) are argued in §5 and §7 and are the reviewer's to veto at sign-off.


11. Verification plan

Unit specs

attendance-justifications.service.spec.ts (splits alongside the service into three files, cases moving with their subject): - Defect 1, the case that matters: a caller holding attendance.justify without the referent role gets the same canEdit from create, update and list for one row. No policy admits such a caller today (§4), so this context has to be constructed by hand — which is the point. The test encodes the invariant "one row, one answer, whoever asks" so that the future policy change which makes it reachable finds a guard already in place, instead of shipping the disagreement. - canEdit remains true only for a future row on a writable link, and false for: a past row, a today-dated row, a read-only link, a student session. - Defect 2: create and update invoke the supersede branch, the re-stamp and the covering sweep in the same order — asserted on mock invocation order, not just presence. - The extracted helpers are each exercised once through create and once through update, proving both paths share them rather than having drifted copies. - listForAdmin rows carry no canEdit key at all.

New filter cases: to-justify threads studentId and from into its query and omits both when absent; the family list threads kind; suggestedKind is ABSENCE for an ABSENT event and LATE_ENTRY for a late arrival.

studentName: present on family list rows, on the POST/PATCH responses, and built through formatPersonName (assert the helper is called, so the inlined join cannot creep back).

E2E — test/attendance-justifications.e2e-spec.ts

  • A referent with several children filters to-justify by studentId and gets only that child's open events; from excludes an earlier open day.
  • The family list carries the right studentName per row across three children — the assertion that would have caught the missing join.
  • The office list response has no canEdit property (not.toHaveProperty, not a falsy check — a false value must fail this test).
  • suggestedKind on a pending row, submitted back verbatim as kind, resolves the event. That round-trip is the real contract: whatever the server suggests must be accepted.

Regression guard

The full existing family + justification e2e suites must pass unchanged. Everything here is additive or internal except the office canEdit removal, so any other diff in those suites is a bug in this change.

Manual verification

Load the justification-table modal as a referent with two children and confirm it renders names and filters with GET /students removed from the page's network calls.


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-08-10
  • Chat reference: approved by Fabio in chat 2026-08-10 ("sign off"), after three scope decisions taken by explicit choice (service split only · ordering unchanged · office-settled gap unchanged) and a walkthrough of the change classification in §5 — which established that the office canEdit removal is the single breaking change and was accepted on that basis.

Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.