Back to Journal

Wiring up real-time case assignments

Listen to this articleAI narration
0:00 / 0:00

Doctor A takes a case. Doctor B clicks the same one five seconds later and gets an error. Classic race condition on a shared queue - the kind that generates support tickets and confused users.

The fix: real-time updates via Ably. When someone claims a case, everyone else sees it vanish from their list immediately. The implementation spans a PHP Fat-Free backend and a Next.js 15 frontend, with a subtle edge case around counseling vs. regular cases that almost slipped through.

The Problem Space

The client's case management system had a classic race condition issue. Multiple practitioners viewing the "Nya ärenden" (new cases) queue could attempt to take the same case. The first one wins, but the others don't know until they click and get an error - or worse, refresh the page and wonder where the case went.

The Race Condition ProblemDoctor AViews Case #42Doctor BViews Case #42DatabaseCase #42: nullTakes case (t=0ms)Takes case (t=50ms)Case already taken!Doctor B sees stale data

The solution: real-time WebSocket updates via Ably. When a case assignment changes, broadcast to all connected clients immediately.

Architecture Overview

The implementation follows a pub/sub pattern with the backend as the sole publisher and multiple frontend subscribers:

Ably Real-time ArchitecturePHP BackendFat-Free Frameworkably/ably-php SDKPublisher OnlyAbly CloudChannel: case-assignmentsEvent: case-assignedNext.js FrontendReact 18 + App Routerably/react hooksSubscriber OnlyFrontend SubscribersCaseTableRemoves taken casesUpdates tab countsAdminSideMenuBadge countsRegular + CounselingpageClient (detail)Warning toast ifcase taken by other

Backend Implementation

The Ably Service

The PHP service follows a singleton pattern using the Fat-Free Framework's Prefab class. It maintains a single AblyRest instance per request, initializing it lazily on first use with the API key from environment variables. If the key is missing, it throws an exception rather than failing silently.

The main method, publishCaseAssignment, takes four parameters: the case ID, the new practitioner ID (which may be null for unassignments), the previous practitioner ID for tracking reassignments, and an optional counseling flag.

The method determines the event type for debugging and analytics purposes. If the new practitioner ID is null, it's an unassignment. If there was a previous practitioner different from the new one, it's a reassignment. Otherwise, it's a standard assignment.

The payload includes the case ID, both practitioner IDs, a timestamp, the event type, and the counseling flag if provided. This gets published to the "case-assignments" channel with the event name "case-assigned".

Critically, the entire publish operation is wrapped in a try-catch block. Ably failures get logged but don't break the case update. This fail-safe approach ensures a third-party service issue doesn't impact core functionality.

Key design decisions include using a singleton pattern for connection reuse, fail-safe error handling, and including the counseling flag for proper frontend filtering.

Controller Integration

The PatientCase controller hooks into Ably after a successful database update. Before applying any changes, it stores the previous practitioner ID. After the database save succeeds, it checks if practitionerId was included in the update payload using array_key_exists.

If the practitioner ID was changed (new value differs from previous), it retrieves the counseling flag from the case entity and calls the Ably publish method with all four parameters: case ID, new practitioner ID, previous practitioner ID, and counseling status.

Backend Event FlowPATCH /cases/{id}{practitionerId: 5}Store Previous$prev = nullDB Update$u->save()Ably Publishcase-assignedPayload StructurecaseId: 42, practitionerId: 5,previousPractitionerId: null, counseling: false

Frontend Implementation

Authentication Route

The frontend needs Ably tokens for secure WebSocket connections. A Next.js API route handles token generation. It first verifies the user is logged in, returning a 401 if not. Then it checks for the ABLY_API_KEY environment variable, returning a 500 if missing.

With a valid user and API key, it creates an Ably Rest client and generates a token request. The token parameters include a client ID based on the user's ID, capabilities allowing subscribe and publish on case-related channels and subscribe on all channels, and a one-hour time-to-live.

Provider Setup

The Ably provider wraps the application as a client component. It creates a memoized Ably Realtime client configured with the auth endpoint URL and GET method. This client instance gets passed to the AblyReactProvider from the Ably React library, which makes it available to all child components via React context.

Component Subscriptions

Each component that needs real-time updates creates its own listener using the useChannel hook. The CaseTable component, for example, subscribes to the "case-assignments" channel and listens for "case-assigned" events.

When an event arrives, it extracts the case ID, practitioner IDs, and counseling flag from the message data. If the counseling flag is true, the component returns early - this is critical for the filtering described below.

If a practitioner ID is present (meaning a case was assigned, not unassigned), the component filters the case from the local list. If the list actually changed and the practitioner isn't the current user, it shows a toast notification in Swedish: "Ett ärende har tagits av en annan användare" (A case has been taken by another user).

The Counseling Edge Case

Here's where things got interesting. The platform has two case types: regular cases for standard medical consultations, and counseling cases for rådgivning (advisory) sessions.

They share the same backend but display in different views. Without proper filtering, a regular case assignment would decrement the counseling counter - or vice versa.

Counseling Flag RoutingAbly Eventcounseling: true | false | nullcounseling: trueAdminSideMenuunassignedCounseling--CaseTable: ignoredcounseling: false/nullAdminSideMenuunassignedCases--CaseTable: removes caseif (counseling === true) return;

The fix was simple but critical - every component that shouldn't react to counseling cases needs an early return checking if counseling equals true.

The AdminSideMenu handles both counters with conditional logic. If the counseling flag is true, it updates the counseling count: decrementing when a case goes from unassigned to assigned, incrementing when going the other direction. If counseling is false or null, the same logic applies to the regular cases count. Both use Math.max with zero to prevent negative counts.

Persistent Toast Enhancement

A UX improvement came up during implementation: when a doctor is viewing a case detail page and another doctor takes that case, the warning toast should persist until dismissed - not auto-fade after three seconds.

The existing toast system only supported auto-dismissing toasts, using setTimeout to remove them after three seconds.

We extended the Toast interface with an optional persistent boolean property. The showToast function now accepts this parameter, defaulting to false. When creating a toast, it only sets up the setTimeout auto-dismiss if persistent is false. Persistent toasts stay visible until the user manually dismisses them via a close button.

Accompanying CSS changes added a fade-in-only animation for persistent toasts and styled the close button with transparent background, pointer cursor, and hover opacity transitions.

Verifying Existing Flows

Critical question: do these changes break anything?

Impact Analysis: Existing FlowsCarasent Close FlowPUT /cases/{id}{closedAt: "..."} - no practitionerIdTake Case FlowPUT /cases/{id}{practitionerId: 5} - publishesMove Case FlowPUT /cases/{id}{practitionerId: 8} - publishesAbly code only triggers when 'practitionerId' is in the update payloadCarasent close uses closedAt only - Ably block is never reached

The backend check using array_key_exists on practitionerId ensures that the Carasent close flow, which only sends closedAt, never triggers Ably. Case updates with various fields only trigger Ably if practitionerId is included. The move case flow correctly triggers Ably because it sends practitionerId.

Cleanup: Removing Unused Code

The original branch included real-time message delivery for the chat interface. After review, we decided to keep only case assignment updates for this release.

Backend cleanup kept the Ably service with just publishCaseAssignment, the PatientCase controller with assignment hooks, and the ably/ably-php composer dependency. The message-related methods like publishMessage, prepareMessageForAbly, and publishMessageData were removed from the Ably service. Changes to the Message and Attachment controllers were reverted. These can be added back when the chat real-time feature is properly scoped.

Files Modified

On the backend, three files were affected: a new Ably service class, modifications to the PatientCase controller for the Ably hooks, and the composer.json for the ably/ably-php dependency.

On the frontend, ten files were modified. Three new files were created: the Ably auth API route, the AblyProvider component, and an Ably utility library. The root layout was modified to wrap with AblyProvider. The CaseTable and CaseTableMenu components gained subscriptions with counseling filtering. The AdminSideMenu got dual counter subscription logic. The case detail page client received warning toast functionality. And the Toaster component and its styles were updated for the persistent option.

Takeaways

First, backend as sole publisher simplifies the security model. Frontend only subscribes, never publishes. No need to scope channel capabilities per-user for case assignments.

Second, fail-safe integration matters. The try-catch around Ably publishing ensures a third-party service failure doesn't break core functionality. Log it, move on.

Third, type flags matter. The counseling edge case was subtle. Same event, same channel, but different business meaning. Without the flag, counts would drift out of sync.

Fourth, preserve existing behavior. The array_key_exists check ensures we only publish when relevant. Touching a case for any other reason doesn't trigger spurious events.

Fifth, UX details matter. The persistent toast seems minor but is important for critical notifications. A doctor needs to acknowledge that their case was taken, not just glimpse it before it fades.

Real-time features are deceptively complex. The WebSocket connection is the easy part. The hard part is making sure every component reacts correctly to every event variant, and that existing flows remain untouched.