Skip to content

Latest commit

 

History

History
181 lines (142 loc) · 10.6 KB

flows.md

File metadata and controls

181 lines (142 loc) · 10.6 KB

Key Flows

Sequence diagrams for the four core interaction flows. Each diagram is followed by a plain-English walk-through of every significant step.


1. Member Attendance Marking (Geofenced)

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
Loading

Step-by-step:

  1. The member opens the ProximityCard component which starts a GPS watch via useGeolocation.ts. The hook calls navigator.geolocation.watchPosition (stable useRef for the watch ID, fixes a prior bug where a plain object was used).
  2. Online path: Coordinates + ledger ID are posted to /attendance/mark. The backend queries the DailyLedger row for the geofence target coordinates and calls geo_fence.check_geofence(). The function runs a Haversine 2D distance check then validates |user_alt - target_alt| < 4m to prevent members on adjacent floors from registering.
  3. If the check passes, a VerificationLedger row is upserted (idempotent, re-marking is allowed, last write wins).
  4. Offline path: The mark is written to the attendance-queue IndexedDB store. The service worker's background-sync tag sync-attendance is registered. On reconnect, the SW replays the queue to /attendance/mark and clears the store entry on any response below 500, since a refusal will not change on a retry.

2. Staff Absence Request (Reverse RSVP)

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}}
Loading

Step-by-step:

  1. Staff submits an absence request via the Absence Requests tab. The ReverseRsvpLog row is created with approval_state = PENDING_VERIFICATION.
  2. The API resolves the staff's reporting_line_manager user ID and, once the response is out, sends a ABSENCE_APPROVAL_REQUIRED WebSocket event. If the manager is connected, they see a notification badge in real time.
  3. 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 ORG_ADMIN, or a UNIT_ADMIN for the non-admin accounts of their unit), and whoever decides becomes the approver on record.
  4. On approval, services/reverse_rsvp.py updates every DailyLedger entry on the target date where the staff is active_lead_id to operational_state = ON_LEAVE. This cascades the absence into the live schedule.
  5. A ABSENCE_DECISION WebSocket event is sent to the staff member so they see the outcome immediately without polling.

3. Guest Handshake

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, visit_expires_at}
    end
    KI-->>G: Approved, or declined

    Note over KI,G: The visitor can follow the same code<br/>from a phone at /guest/visit

    Note over API,DB: Every 5 minutes: approved visits past visit_expires_at
    API->>WS: broadcast(target, and the approver, GUEST_VISIT_EXPIRED)
    WS-->>Fac: {event: GUEST_VISIT_EXPIRED,<br/>payload: {transaction_reference, guest_name, visit_expires_at}}
    Fac->>API: PATCH /guest/{id}/extend<br/>{minutes: 30}
    API->>DB: UPDATE visit_expires_at,<br/>overstay_notified_at = NULL
Loading

Step-by-step:

  1. 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.
  2. The check-in POST carries that key rather than anyone's bearer token. The server creates a GuestGateRegistry row, answers with a visit code for the visitor and, once the response is out, pushes a GUEST_HANDSHAKE_REQ frame to the target staff's WebSocket connection.
  3. If the staff is connected, their NotificationPanel badge increments and the Interaction Desk tab shows the incoming request within milliseconds.
  4. Staff approves or declines, and the GuestGateRegistry row is updated. Nothing is pushed to the kiosk, which has nobody signed in to push to. It asks GET /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.
  5. An approval gets an end, GUEST_VISIT_LENGTH_MINUTES after the decision, and the visitor reads that time from the same page their code opens. Zero, the default, leaves the visit open-ended and there is nothing more to this step.
  6. A job every five minutes finds approved visits whose end has passed and pushes GUEST_VISIT_EXPIRED once, to the target and to whoever decided the visit if that was somebody else. The Interaction Desk lists the same visits from GET /guest/overstays, for a desk that had no socket open when the notice went out, and PATCH /guest/{id}/extend gives one of them more time, up to GUEST_VISIT_EXTENSION_MAX_MINUTES at a go. An extension clears the record that anybody was told, so a second overrun announces itself as the first did.

4. Nightly Ledger Generation

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/>whose rule gives tomorrow] --> 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]
Loading

Step-by-step:

  1. APScheduler (configured in backend/app/main.py) runs cron/ledger_generator.materialize_horizon() at 23:00 in ORG_TIMEZONE. It writes every missing day from tomorrow through LEDGER_HORIZON_DAYS ahead (28 by default), in batches, reading the existing days once rather than checking slot by slot. POST /ingestion/upload-csv calls it for the imported cycle, so a new cycle has its days straight away. A server that starts also runs the single-day generate_daily_ledger_entries() 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.
  2. Only cycles that are open and whose date_bounds_start to date_bounds_end overlaps the window, both ends included, count. If there are none (e.g., term break), the job is a no-op.
  3. Each StructuralMasterSlot of those cycles gets a day on every date in the window that its rrule gives, counted from its dtstart, and falls inside the cycle's own dates.
  4. For each slot, an existence check is performed, backed by a unique key on target_date and master_slot_id (uq_daily_ledger_slot_date). This makes the job fully idempotent, safe to re-run manually via POST /ingestion/generate-ledger and safe on several instances at once, without creating duplicates.
  5. New rows are inserted with operational_state = SCHEDULED and the slot's lead. Admins (a unit admin only within their own unit) can subsequently mutate the row (substitute lead, delivery format, geofence coords) via PATCH /schedule/ledger/{id}.