Skip to content

Latest commit

 

History

History
173 lines (135 loc) · 9.11 KB

File metadata and controls

173 lines (135 loc) · 9.11 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 SUPER_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}
    end
    KI-->>G: Approved, or declined

    Note over KI,G: The visitor can follow the same code<br/>from a phone at /guest/visit
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.

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/>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]
Loading

Step-by-step:

  1. APScheduler (configured in backend/app/main.py) runs cron/ledger_generator.generate_daily_ledger_entries() for tomorrow at 23:00 in ORG_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.
  2. Only cycles that are open and whose date_bounds_start to date_bounds_end includes tomorrow, both ends included, count. If there are none (e.g., term break), the job is a no-op.
  3. For tomorrow's day_of_week_index, the StructuralMasterSlot rows of those cycles are fetched.
  4. For each slot, an existence check is performed, backed by a unique key on target_date and master_slot_id (migration 017). 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}.