Sequence diagrams for the four core interaction flows. Each diagram is followed by a plain-English walk-through of every significant step.
sequenceDiagram
actor Stu as Member (PWA)
participant SW as Service Worker
participant IDB as IndexedDB
participant API as FastAPI /attendance/mark
participant GF as geo_fence.py
participant DB as PostgreSQL
Stu->>Stu: Opens ProximityCard<br/>for today's class
alt Online
Stu->>API: POST /attendance/mark<br/>{ledger_id, member_id, lat, lon, alt}
API->>DB: SELECT DailyLedger WHERE id = ledger_id
DB-->>API: lat_target, lon_target, alt_target, radius_m
API->>GF: check_geofence(user_coords, target_coords, radius)
GF-->>API: inside=true / false
alt Inside geofence
API->>DB: UPSERT VerificationLedger (PRESENT)
API-->>Stu: 200 {status, member_id, marking_status}
else Outside geofence
API-->>Stu: 400 {detail: "Location outside geofence boundary"}
end
else Offline
Stu->>IDB: enqueueAttendance(record)
SW->>SW: Register background-sync tag<br/>"sync-attendance"
Note over SW: Fires when connectivity restored
SW->>API: POST /attendance/mark (replayed)
API->>DB: UPSERT VerificationLedger
API-->>SW: 200
end
Step-by-step:
- The member opens the ProximityCard component which starts a GPS watch via
useGeolocation.ts. The hook callsnavigator.geolocation.watchPosition(stableuseReffor the watch ID, fixes a prior bug where a plain object was used). - Online path: Coordinates + ledger ID are posted to
/attendance/mark. The backend queries theDailyLedgerrow for the geofence target coordinates and callsgeo_fence.check_geofence(). The function runs a Haversine 2D distance check then validates|user_alt - target_alt| < 4mto prevent members on adjacent floors from registering. - If the check passes, a
VerificationLedgerrow is upserted (idempotent, re-marking is allowed, last write wins). - Offline path: The mark is written to the
attendance-queueIndexedDB store. The service worker'sbackground-synctagsync-attendanceis registered. On reconnect, the SW replays the queue to/attendance/markand clears the store entry on any response below 500, since a refusal will not change on a retry.
sequenceDiagram
actor Fac as Staff
actor Mgr as Line Manager
participant API as FastAPI
participant RSVP as reverse_rsvp.py
participant WS as WebSocket Hub
participant DB as PostgreSQL
Fac->>API: POST /attendance/absence<br/>{target_absence_date, end_date?, context_justification}
API->>DB: INSERT ReverseRsvpLog<br/>approval_state = PENDING_VERIFICATION
API->>DB: SELECT User WHERE id = fac.reporting_line_manager
API-->>Fac: 200 ReverseRsvpResponse
API->>WS: broadcast(manager_id, ABSENCE_APPROVAL_REQUIRED)
WS-->>Mgr: {event: ABSENCE_APPROVAL_REQUIRED,<br/>payload: {log_id, from, date}}
Mgr->>API: PATCH /attendance/absence/{id}/decide<br/>{decision: VERIFIED_APPROVED}
API->>RSVP: commit_absence_override(log_id, decision)
alt VERIFIED_APPROVED
RSVP->>DB: UPDATE ReverseRsvpLog<br/>approval_state = VERIFIED_APPROVED
RSVP->>DB: UPDATE DailyLedger<br/>operational_state = ON_LEAVE<br/>(slots from target_absence_date<br/>through end_date)
else VERIFIED_DENIED
RSVP->>DB: UPDATE ReverseRsvpLog<br/>approval_state = VERIFIED_DENIED
end
API-->>Mgr: 200 {status}
API->>WS: broadcast(staff_id, ABSENCE_DECISION)
WS-->>Fac: {event: ABSENCE_DECISION,<br/>payload: {log_id, decision}}
Step-by-step:
- Staff submits an absence request via the Absence Requests tab. The
ReverseRsvpLogrow is created withapproval_state = PENDING_VERIFICATION. - The API resolves the staff's
reporting_line_manageruser ID and, once the response is out, sends aABSENCE_APPROVAL_REQUIREDWebSocket event. If the manager is connected, they see a notification badge in real time. - The manager opens Pending Approvals and approves or denies. If the manager has since been deactivated, the request appears instead under Proxy Management on an admin's dashboard (any
SUPER_ADMIN, or aUNIT_ADMINfor the non-admin accounts of their unit), and whoever decides becomes the approver on record. - On approval,
services/reverse_rsvp.pyupdates everyDailyLedgerentry on the target date where the staff isactive_lead_idtooperational_state = ON_LEAVE. This cascades the absence into the live schedule. - A
ABSENCE_DECISIONWebSocket event is sent to the staff member so they see the outcome immediately without polling.
sequenceDiagram
actor G as Guest (Kiosk)
participant KI as /guest/kiosk (PWA)
participant API as FastAPI /guest/register-checkin
participant WS as WebSocket Hub
actor Fac as Staff (dashboard)
participant DB as PostgreSQL
G->>KI: Fills check-in form<br/>(name, phone, org, staff, intent)
KI->>API: POST /guest/register-checkin<br/>X-API-Key (the kiosk's)
API->>DB: INSERT GuestGateRegistry<br/>handshake_status = PENDING_VERIFICATION
API->>DB: SELECT User WHERE id = target_staff_id
API-->>KI: 200 {registration_state, reference_token, visit_code}
API->>WS: broadcast(staff_id, GUEST_HANDSHAKE_REQ)
KI-->>G: Shows the visit code<br/>"Please wait."
WS-->>Fac: {event: GUEST_HANDSHAKE_REQ,<br/>payload: {transaction_reference, guest_name, organization, intent}}
Note over Fac: NotificationPanel shows badge
Fac->>API: PATCH /guest/{id}/decide<br/>{decision: VERIFIED_APPROVED}
API->>DB: UPDATE GuestGateRegistry<br/>handshake_status = VERIFIED_APPROVED
API-->>Fac: 200 {status, guest}
loop Every 5 seconds until decided
KI->>API: GET /guest/visit/{code}
API-->>KI: 200 {handshake_status, timestamp_marked}
end
KI-->>G: Approved, or declined
Note over KI,G: The visitor can follow the same code<br/>from a phone at /guest/visit
Step-by-step:
- The Guest Kiosk (
/guest/kiosk) is a page nobody signs in to. The terminal holds an API key an admin issues it once, and sends it with every call. It searches the staff directory (GET /guest/directory?name=…) to let the guest pick the right person. - The check-in POST carries that key rather than anyone's bearer token. The server creates a
GuestGateRegistryrow, answers with a visit code for the visitor and, once the response is out, pushes aGUEST_HANDSHAKE_REQframe to the target staff's WebSocket connection. - If the staff is connected, their NotificationPanel badge increments and the Interaction Desk tab shows the incoming request within milliseconds.
- Staff approves or declines, and the
GuestGateRegistryrow is updated. Nothing is pushed to the kiosk, which has nobody signed in to push to. It asksGET /guest/visit/{code}with the code the check-in returned, and the visitor can ask the same from a phone at/guest/visit. The row keeps only the code's hash.
flowchart TD
T([APScheduler fires at 23:00 in ORG_TIMEZONE]) --> Q1
Q1[Query open PlanningCycles<br/>whose dates include tomorrow] --> Q2
Q2[Query their StructuralMasterSlots<br/>for tomorrows day_of_week_index] --> LOOP
LOOP{For each slot} --> CHK
CHK{DailyLedger row<br/>already exists<br/>for date + slot?}
CHK -->|Yes| SKIP[Skip, idempotent]
CHK -->|No| INS
INS[INSERT DailyLedger
target_date = tomorrow
operational_state = SCHEDULED
active_lead_id = slot.primary_lead_id
delivery_format = PHYSICAL
lat/lon/alt from slot room registry] --> LOOP
SKIP --> LOOP
LOOP -->|done| LOG[Log: N rows generated, M skipped]
Step-by-step:
- APScheduler (configured in
backend/app/main.py) runscron/ledger_generator.generate_daily_ledger_entries()for tomorrow at 23:00 inORG_TIMEZONE. A server that starts also runs it once for today, and for tomorrow too if it starts at or after 23:00, because the scheduler keeps no record of a run it missed while the server was down. The catch-up never writes a date that has already passed. - Only cycles that are open and whose
date_bounds_starttodate_bounds_endincludes tomorrow, both ends included, count. If there are none (e.g., term break), the job is a no-op. - For tomorrow's
day_of_week_index, theStructuralMasterSlotrows of those cycles are fetched. - For each slot, an existence check is performed, backed by a unique key on
target_dateandmaster_slot_id(migration 017). This makes the job fully idempotent, safe to re-run manually viaPOST /ingestion/generate-ledgerand safe on several instances at once, without creating duplicates. - New rows are inserted with
operational_state = SCHEDULEDand the slot's lead. Admins (a unit admin only within their own unit) can subsequently mutate the row (substitute lead, delivery format, geofence coords) viaPATCH /schedule/ledger/{id}.