Skip to content

Parent–Teacher Conferences

A conference is a school-wide event on one or more days. Administration creates it as a draft, chooses the participating teachers and their rooms, and publishes it: every participant then receives one appointment slot per grid position, all closed. Teachers open the slots they offer; administration opens booking; referents book a slot with a teacher of their children for one or more of those children. A booking can be cancelled by the referent or by administration; the slot becomes free again and the referent is told in the app.

Canonical implementation: src/conferences/ (one module, three audiences, no row ownership outside the participant and the referent):

File Owns
conference-grid.ts Pure appointment grid of a date window and the diff of stored slots against a new grid
conference-dates.ts Pure validation of the dates list and day matching against the stored days
conference-status.ts Pure status machine (CONFERENCE_TRANSITIONS, checkTransition, hasSlots)
conference-availability.ts Pure rule of who may change availability in which status; isFutureSlot
conferences-admin.controller.ts, conferences-admin.service.ts Draft CRUD, grid preview, status commands, participants, rooms, edits with the grid diff
conferences-teacher.controller.ts, conferences-teacher.service.ts The teacher's own conferences and schedule
conferences-availability.service.ts The one availability write behind the teacher route and the admin route
conferences-family.controller.ts, conferences-family.service.ts Eligible teachers, free slots, own bookings, the booking transaction
conferences-bookings.service.ts Cancellation (family and admin) and the admin bookings list
conferences.queries.ts Row locks, eligibility predicates, schedule projection, includes shared by the services
conferences-{admin,teacher,family}.policy.ts Role admission of each audience
dto/ Request and response shapes; conferences.swagger.ts the OpenAPI decorators

1. Model

Table Row Notes
parent_teacher_conferences One conference of one academic year Title (max 200), status, slotDurationMinutes (CHECK > 0), changeoverMinutes (CHECK >= 0), createdByUserId. The year is the active year at creation and never changes
conference_dates One day of a conference date, startTime/endTime as zero-padded school-local HH:mm text (CHECK end > start); unique per (conference, date)
conference_participants One teacher taking part Unique per (conference, teacher); availabilitySubmittedAt is the last save of the teacher's own availability
conference_teacher_rooms The room of one participant on one date Exists only once a room was assigned; no row, or a NULL room (the room FK is SetNull), reads as "not yet assigned"
conference_slots One appointment position of one participant on one date startTime, endTime, isAvailable (default false); unique per (participant, date, startTime). There is no booked column
conference_bookings One booking of one slot status CONFIRMED / CANCELLED, the referent, conferenceId and participantId copied from the slot, createdByUserId, cancelledAt, cancelledByUserId
conference_booking_students The students a booking is about Composite key (booking, student)
  • All seven tables are tenant-bearing with FORCE RLS (Class S) and a Restrict tenant FK; see Multitenancy. Children of a conference, participant or slot cascade on delete.
  • No database constraint backs the booking rules (one CONFIRMED booking per slot, one per referent and participant, no overlap for a referent). They are enforced by the booking endpoint under the conference FOR SHARE lock, the referent advisory lock and the slot row lock (§6). Any new path that writes a CONFIRMED booking must take the same locks in the same order, or it can create a duplicate the database accepts.
  • A slot is BOOKED when a CONFIRMED booking holds it. The slot status the API returns (AVAILABLE / UNAVAILABLE / BOOKED) is computed in teacherScheduleSelect (conferences.queries.ts); both the teacher's own schedule and the admin's read of a participant's schedule use that one function.
  • Participants point at the year's Teacher row, so a teacher is identified by teacher.userId within the conference year.

2. Status machine

DRAFT ──publish──→ PUBLISHED ──open-booking──→ BOOKING_OPEN ⇄ BOOKING_CLOSED
                                                   ↑   close-booking   │
                                                   └──── open-booking ─┘

conference-status.ts is the only place the transitions live. There is no way back to DRAFT, and no status for an ended conference.

Command Route From To Extra conditions
publish POST /conferences/:id/publish DRAFT PUBLISHED At least one date (else 409 CONFERENCE_NOT_PUBLISHABLE / NO_DATES), then at least one participant (NO_PARTICIPANTS). Inserts the slots (§3)
openBooking POST /conferences/:id/open-booking PUBLISHED, BOOKING_CLOSED BOOKING_OPEN From BOOKING_CLOSED only while the conference has a slot that has not started (any slot, available or not); else 409 CONFERENCE_NO_FUTURE_SLOTS
closeBooking POST /conferences/:id/close-booking BOOKING_OPEN BOOKING_CLOSED Existing bookings stay

A command from another status is 409 CONFERENCE_INVALID_STATUS. Every command takes the exclusive conference lock first and re-reads the status under it (§6).

What each status allows:

Status Admin edit Participants and rooms Availability Families
DRAFT Title, duration, changeover, dates; delete Add, remove, set rooms Not available (no slots) Cannot see it
PUBLISHED Title always; duration, changeover and dates while no booking is confirmed Add, remove (while the teacher holds no confirmed booking), set rooms Teacher and admin Cannot see it
BOOKING_OPEN As PUBLISHED As PUBLISHED Teacher and admin See it, book, cancel own bookings
BOOKING_CLOSED As PUBLISHED As PUBLISHED Admin only See it and their bookings; cannot book or cancel

A draft is deleted by DELETE /conferences/:id (409 CONFERENCE_INVALID_STATUS outside DRAFT); the dates, participants and rooms go with it.

3. Dates and grid

Dates

dates is a list of { date, startTime, endTime } (at most 60, CONFERENCE_MAX_DATES). On create it is optional; on PATCH /conferences/:id it is the full list: matchConferenceDates matches requested and stored days by calendar day. A day in both keeps its row id (and so its room assignments), a day only requested is inserted, a day only stored is deleted; a kept day whose hours differ is updated in place.

validateConferenceDates returns the first violation, checking each reason over the whole list before the next. The result is 422 CONFERENCE_DATE_INVALID carrying the offending day and one reason:

Order Reason Rule
1 DUPLICATE A day appears twice
2 END_NOT_AFTER_START The window ends at or before its start
3 PAST A new day is today or earlier (school-local today)
4 NO_SLOT_FITS The window holds no appointment. Checked for added days, days whose hours changed, and, when duration or changeover change, every stored day after today
5 PAST_DATE_LOCKED A stored day that is today or earlier is removed or has its hours changed

A stored day that is today or earlier and kept as it is stays out of the NO_SLOT_FITS re-check even when duration or changeover change. An edit with no field is 400 VALIDATION_FAILED; an edit that changes nothing writes nothing and records no audit event.

After publication only the title is free once any booking is confirmed: a change of duration, changeover or the day list first checks the confirmed count and answers 409 CONFERENCE_HAS_CONFIRMED_BOOKINGS, whose message gives the count, before date validation runs. With no confirmed booking the edit is allowed in PUBLISHED, BOOKING_OPEN and BOOKING_CLOSED.

Grid

generateDateGrid(window, slotDurationMinutes, changeoverMinutes) walks the window from its start: a slot [start, start + duration) is emitted while it ends at or before the window end, and the next one starts changeover minutes after the previous end. The result is the chronological slots, the changeovers (the gaps between two consecutive slots; none before the first or after the last) and unusedTail (the window left after the last slot, the whole window when no slot fits, null when nothing is left). A non-positive duration generates nothing. generateGrid maps it over the dates and carries any extra field.

GET /conferences/:id/grid-preview returns the grid every teacher gets from the current duration, changeover and dates; nothing is stored. A date with no appointment shows an empty appointments list and the whole window as unusedTail.

Materialization and grid diff

Slots exist only from publication on (hasSlots(status)).

  • Publish inserts, for every participant and every grid position of every date, one slot with isAvailable = false (insertSlots). A participant added after publication gets the current grid the same way, closed.
  • Edit after publication. When duration, changeover or the dates change, syncSlots runs diffSlots(stored, participantIds, gridByDateId) and applies it in the same transaction. Slots are keyed by (participantId, conferenceDateId, startTime, endTime): a stored slot still in the grid is kept with its availability untouched; a stored slot no longer in the grid, of a participant or day no longer present, or a duplicate of a kept key, is deleted (a cancelled booking on it goes with it); a grid position with no stored slot is inserted closed. A title-only edit never touches slots.
  • Removing a day deletes its slots and room rows by cascade; a day that keeps its row keeps its room assignments even when its hours move.

4. Admin side

All routes are under @Controller('conferences'). Admin writes; the other management presets and SEN Manager read (§8).

Verb Path Scope / grant Purpose
GET /conferences conferences.setup READ Conferences of a year (year query, else the active year; none active returns []), newest first; status filter
POST /conferences setup WRITE Create a draft in the active year (409 NO_ACTIVE_ACADEMIC_YEAR without one)
GET /conferences/:id setup READ Detail: dates, participants with the room of every date and their confirmed-booking count
PATCH /conferences/:id setup WRITE Sparse edit (§3)
DELETE /conferences/:id setup WRITE Delete a draft
GET /conferences/:id/grid-preview setup READ Grid preview
POST /conferences/:id/publish, /open-booking, /close-booking setup WRITE Status commands
GET /conferences/:id/teacher-candidates setup READ Eligible teachers (departmentId, gradeId, search), each with isParticipant
POST /conferences/:id/participants setup WRITE Add teachers (teacherIds, 1 to 500)
DELETE /conferences/:id/participants/:participantId setup WRITE Remove a participant
PUT /conferences/:id/participants/:participantId/rooms setup WRITE Set the room per date
GET /conferences/:id/participants/:participantId/schedule setup READ The participant's schedule, shaped as the teacher's own
PATCH /conferences/:id/participants/:participantId/availability setup WRITE Availability on the teacher's behalf (§5)
GET /conferences/:id/bookings conferences.bookings READ Bookings by day and time, filters participantId, status, date; every status by default
POST /conferences/:id/bookings/:bookingId/cancel action conferences.cancel_booking and setup WRITE Cancel a booking (§7)

Module registration order is an invariant. ConferencesModule registers the family controller, then the teacher controller, then the admin controller. The audience prefixes conferences/family and conferences/teacher must register before the admin conferences/:id/... routes, or their first segment is read as an id. Inside the family controller the static bookings routes likewise come before the :id routes.

Participants. Add is all or nothing and runs in every status: each requested teacher must be eligible (409 CONFERENCE_TEACHER_NOT_ELIGIBLE, naming the ids), then none may already take part (409 CONFERENCE_PARTICIPANT_EXISTS). Remove is refused while that participant holds a confirmed booking (409 CONFERENCE_HAS_CONFIRMED_BOOKINGS, variant PARTICIPANT: the message names the teacher and the count); the participant's rooms and slots go by cascade. An unknown participant, date or slot answers 404 NOT_FOUND.

Rooms. PUT .../rooms takes items[] { conferenceDateId, roomId | null } (each date once). Every date must belong to the conference (404 NOT_FOUND), every room to the conference's academic year (422 CONFERENCE_ROOM_INVALID); null clears the assignment. The response is the participant with the room of every date. An item that changes nothing is not audited.

Eligibility

One predicate (eligibleTeacherWhere, conferences.queries.ts) serves the candidates list, the add command, the family reads and the booking transaction. A teacher is eligible when they are ACTIVE in the conference's academic year and, on school-today, teach at least one participating student (status ENROLLED, participatingStudentWhere) of that year through a course: a dated teaching episode on a subject group the student is rostered in, or the combined-class sibling of such a group. It calls teachersTeachingStudentsOn (src/communications/recipient-policy.queries.ts) with includeSupervision: false and includeHomeroomTutor: false: a homeroom tutor who teaches none of the student's courses, and a supervisor of an activity or a break duty, are not teachers of the student here. The predicate is evaluated at each call, so a teacher who stops teaching after publication stays a participant but disappears from a family's view of eligible teachers; bookings already made stay.

5. Availability

ConferencesAvailabilityService.update is the single write behind PATCH /conferences/teacher/:id/availability and PATCH /conferences/:id/participants/:participantId/availability. The body is items[] { slotId, isAvailable } (1 to 1000, each slot once); true opens a slot to families, false closes it. The response is the participant's schedule.

Who may write in which status (checkAvailabilityWrite):

Caller PUBLISHED BOOKING_OPEN BOOKING_CLOSED DRAFT
Teacher (own grid) yes yes 409 CONFERENCE_AVAILABILITY_LOCKED, also for a teacher added after the close 404 (a draft does not exist for a teacher)
Admin (any participant) yes yes yes 409 CONFERENCE_INVALID_STATUS

The routine is one transaction: the school-local now is read, the conference is locked FOR SHARE, the participant is resolved (the teacher's own through findOwnParticipant, the admin's by id), the status rule is applied, and the listed slots are locked FOR UPDATE in id order (lockParticipantSlotsForUpdate). All checks run before any write, so one bad slot changes nothing:

  1. A listed slot that does not exist or is not the participant's is 404 NOT_FOUND (a stranger's slot looks missing).
  2. A slot held by a confirmed booking is 409 CONFERENCE_SLOT_BOOKED; the message lists every such slot as day and times (slots, built from the locked rows, no extra query).
  3. A slot that has already started is 409 CONFERENCE_SLOT_IN_PAST, naming the earliest. A slot is in the future on a later day, or today and starting strictly after the school-local time (isFutureSlot).

Only slots whose value changes are written. A teacher save sets the participant's availabilitySubmittedAt even when nothing changed. An admin save never touches it, and it is audited as conference_availability.updated only when at least one slot changed (§9).

6. Family side and the booking transaction

Family routes live under @Controller('conferences/family') and are admitted for the role referent. Rows are fenced to the caller's own Referent (the policy's predicate userId = caller); a caller without one gets [] from the lists and 404 from the single-resource routes.

Verb Path Grant Purpose
GET /conferences/family conferences.bookings READ Conferences in BOOKING_OPEN or BOOKING_CLOSED with at least one teacher of the referent's children (eligibleTeacherCount)
GET /conferences/family/bookings bookings READ Own bookings by day and time; status (default CONFIRMED), conferenceId
POST /conferences/family/bookings/:bookingId/cancel action conferences.cancel_booking Cancel an own booking (§7)
GET /conferences/family/:id/teachers bookings READ The conference's teachers who teach the referent's children, the children each teaches, and the referent's confirmed booking with them; studentId narrows
GET /conferences/family/:id/teachers/:participantId/slots bookings READ The teacher's free slots by day with the day's room
POST /conferences/family/:id/bookings action conferences.book Book a slot

A conference outside BOOKING_OPEN / BOOKING_CLOSED is 404 CONFERENCE_NOT_FOUND for a family. The students considered are the referent's linked students of the conference year who are ENROLLED. The slots route also requires BOOKING_OPEN (409 CONFERENCE_INVALID_STATUS, expected BOOKING_OPEN) and a teacher of the referent's children (404 NOT_FOUND otherwise; unknown and not-yours are one answer). It lists slots that are open, hold no confirmed booking and have not started.

Booking

POST /conferences/family/:id/bookings takes { slotId, studentIds } (1 to 20 students, each once). The whole command is one transaction (ConferencesFamilyService.create) that takes three locks in a fixed order and re-checks every rule under them, so a status change, a teacher toggling the slot, another family or a second tab of the same referent cannot slip past a check:

  1. Conference FOR SHARE (lockConferenceForShare). The status commands, the edits, the participant commands and the room command take the same row FOR UPDATE, so they wait for in-flight bookings and are waited for; bookings, availability writes and cancellations hold it shared and run alongside each other. A conference that is missing or not visible to families is 404 CONFERENCE_NOT_FOUND.
  2. A per-referent advisory lock conference-referent:<tenant>:<conference>:<referent> (lockReferentInConference), held to the end of the transaction: two tabs of one referent, or two overlapping slots, serialize.
  3. The slot row FOR UPDATE (lockSlotForUpdate), scoped to the conference: two referents on one slot, or a teacher toggling it, serialize. A slot not of this conference is 404 NOT_FOUND.

The 409 messages of checks 4 to 6 in the table below are rendered from params that are read only after the check fails, inside the same transaction (describeBooking, the teacher lookup): the success path runs no extra query. Chapter 06 owns the rendering (ERROR_TEXT_PARAMS, ERROR_INTERNAL_PARAMS); the ids (bookingId, slotIds, studentIds) stay server-side. A student who is not linked to the caller is never named: when none of the offending students is the caller's, the code carries reason: NOT_LINKED and the message only says that a selected student is not linked to the account. CONFERENCE_SLOT_NOT_AVAILABLE stays generic, because a reason would tell a family that another family just took the slot.

Under the locks, a slot of a teacher who teaches none of the referent's children is 404 NOT_FOUND. Then the checks, in order, each with its own code:

Order Check Error
1 The conference is BOOKING_OPEN 409 CONFERENCE_INVALID_STATUS
2 The slot has not started 409 CONFERENCE_SLOT_IN_PAST
3 The slot is open and holds no confirmed booking 409 CONFERENCE_SLOT_NOT_AVAILABLE
4 The referent holds no confirmed booking with this teacher in this conference 409 CONFERENCE_BOOKING_TEACHER_TAKEN, the message gives the day and start time of the booking held
5 None of the referent's confirmed bookings overlaps the slot on its day (start < other.end and end > other.start) 409 CONFERENCE_BOOKING_OVERLAP, the message gives the day, start time and teacher of the booking in the way
6 Every named student is linked to the referent and taught by this teacher 409 CONFERENCE_STUDENT_NOT_ELIGIBLE, the message names the teacher and the referent's own students the teacher does not teach

Then the booking and its students are inserted and conference_booking.created is recorded. The insert has no unique-violation fallback: the checks above, under the three locks, are the only guard.

7. Cancellation

ConferencesBookingsService.cancel is shared by POST /conferences/family/bookings/:bookingId/cancel and POST /conferences/:id/bookings/:bookingId/cancel. It reads the booking unlocked only to learn its conference, locks the conference FOR SHARE, locks the booking FOR UPDATE (lockBookingForUpdate) and re-checks everything under the locks:

Rule Family Admin Error
The booking exists and is the caller's (family: own referent; admin: in the conference of the route) yes yes 404 CONFERENCE_BOOKING_NOT_FOUND; an admin route with an unknown conference is 404 CONFERENCE_NOT_FOUND
The conference is BOOKING_OPEN required not required 409 CONFERENCE_BOOKING_NOT_CANCELLABLE, reason BOOKING_CLOSED
The booking is CONFIRMED required required 409 CONFERENCE_BOOKING_NOT_CANCELLABLE, reason CANCELLED
The appointment has not started required required 409 CONFERENCE_BOOKING_NOT_CANCELLABLE, reason PAST

The checks run in the order of the table. A successful cancel keeps the row: status = CANCELLED, cancelledAt, cancelledByUserId. The slot is free again because only CONFIRMED bookings hold a slot; a later booking creates a new row. The response is the booking as the caller sees it (the family shape, or the admin shape with the referent and cancelledByUserId).

In the same transaction the service records conference_booking.cancelled and queues the in-app notification conferences.booking_cancelled for the booking's referent (§9). The notification is sent only after commit, and only when the referent has an active user account.

The admin list GET /conferences/:id/bookings reads the same rows with the referent and who cancelled; an unknown conference is 404, an unknown participantId filter is an empty list.

8. Authorization

Each audience is admitted by role through its policy (conferences-*.policy.ts), which @AppliesPolicy composes into @RequireRoles on the handler. Role admission is by role key, not by grant:

Audience Roles admitted Row fence Grants on top
Admin routes, write admin (ConferencesAdminPolicy) Tenant only: conferences are school-wide conferences.setup WRITE on every write route; the cancel also needs the action conferences.cancel_booking
Admin routes, read The same policy also admits principal, communications_manager, hr, secretary, administrative_assistant, department_head, curriculum_coordinator and sen_manager Tenant only: department_head and curriculum_coordinator read the whole school conferences.setup READ on the GET routes; conferences.bookings READ for the bookings list
Teacher routes teacher (ConferencesTeacherPolicy) The caller's own ConferenceParticipant in a non-draft conference; anything else is 404 conferences.availability READ / WRITE
Family routes referent (ConferencesFamilyPolicy) The caller's own Referent and its linked children conferences.bookings READ; actions conferences.book, conferences.cancel_booking

Who reads, who writes. Only admin writes: it alone holds setup WRITE and the two actions. The eight read-only presets hold setup READ and bookings READ (SEN Manager also availability READ) and no action, so each write route answers 403 INSUFFICIENT_SCOPE or ACTION_NOT_PERMITTED, and a read-only caller never changes a row. Teacher, referent, staff and student are not admitted to the admin routes. Grants reach existing tenants through npm run db:reconcile-rbac (chapter 15), which derives them from expectedPresetGrants; there is no data migration.

A custom role that holds conferences.setup is not admitted to the admin routes, read or write: the role allowlist is checked first, so the grant alone is not enough. A caller whose role is not admitted gets 403 ACTION_NOT_PERMITTED.

The three scopes setup, availability and bookings are gate-only (no field filtering). book and cancel_booking are READ-level actions: the referent preset holds bookings READ only, and ownership is the policy's. Presets, conferences.* only:

Preset Scopes Actions
admin setup, availability, bookings WRITE book, cancel_booking
teacher availability WRITE, bookings READ none
referent bookings READ book, cancel_booking
principal, communications_manager, hr, secretary, administrative_assistant, department_head, curriculum_coordinator setup READ, bookings READ none
sen_manager setup, availability, bookings READ none
staff, student none none

See Permissions; the catalogue is prisma/seed/rbac-catalogue.ts and the grants prisma/seed/roles.ts.

9. Audit and notification

resolveActor once per transaction, one record per logical change (Audit Log):

Entity type Actions
conference created, updated (before/after of what changed), deleted, published (date, participant and slot counts), booking_opened, booking_closed (both with from/to), participants_added, participant_removed, participant_rooms_changed (per-date before/after)
conference_availability updated, anchored on the participant: ids of the opened and closed slots. Admin writes that changed at least one slot only; a teacher's own save is not audited
conference_booking created and cancelled, anchored on the booking: conference, participant, teacher, referent, slot, day, times and student ids; cancelled adds cancelledBy (FAMILY / ADMIN) and actorUserId

Presentation is label-only: the conference has no audit-reader link.

Notification conferences.booking_cancelled (Notifications). Producer: ConferencesBookingsService, once per cancellation, registered inside the transaction and sent after commit with sendAfterCommit. Audience: the booking's referent, when they have an account that is active; no account or a deactivated one is simply not reachable. Both a family cancel and an admin cancel notify the referent. The payload is { bookingId, conferenceId, conferenceTitle, teacherId, teacherName, date, startTime, endTime, cancelledBy } (ConferenceBookingCancelledNotificationPayload in notification.interfaces.ts), target type conference.booking, inbox only: the kind is in NOTIFICATION_IN_APP_ONLY_KINDS and has no email or push specification.

10. Errors

Error codes and their parameter shapes are declared in src/common/constants/error-codes.ts (ErrorParamsMap) and their copy in src/common/i18n/error-messages.catalog.ts; the client receives code and messages only (Frontend error contract). Every CONFERENCE_* code is a 409 except CONFERENCE_NOT_FOUND and CONFERENCE_BOOKING_NOT_FOUND (404) and CONFERENCE_DATE_INVALID and CONFERENCE_ROOM_INVALID (422). A reason (CONFERENCE_DATE_INVALID, CONFERENCE_NOT_PUBLISHABLE, CONFERENCE_BOOKING_NOT_CANCELLABLE) selects a more specific catalogue entry. Unknown participants, dates and slots use the generic 404 NOT_FOUND. See Error Handling.

11. Extending

  • A new status or transition: change CONFERENCE_TRANSITIONS and hasSlots in conference-status.ts, then re-read every place that branches on a status: the availability table in conference-availability.ts, FAMILY_VISIBLE_STATUSES, the family booking checks and the cancel rule.
  • A new slot rule (a cap per teacher, another reason to refuse a booking): add the check to ConferencesFamilyService.create after the locks, with its own code. No database constraint backs the rule, so the locks are what make it hold.
  • A new write that must not race a booking: lock the conference FOR UPDATE if it changes the conference, grid or participants, FOR SHARE if it must run alongside bookings, and take slot locks in id order.
  • Conferences import other modules only through their barrels and the shared recipient predicate; no owner module imports conferences.