Back to Journal

Real-time case assignments with Ably

Today's deep dive: adding real-time case assignment updates to Doktera's practitioner portal. When Doctor A takes a case, Doctor B should see it disappear from their queue instantly - no page refresh required. The implementation spans a PHP Fat-Free Framework backend and a Next.js 15 frontend, connected via Ably's pub/sub infrastructure.

The Problem Space

Doktera'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 with the Fat-Free Framework's Prefab class:

<?php
 
namespace Services;
 
use Ably\AblyRest;
 
class Ably extends \Prefab {
    private static ?AblyRest $instance = null;
 
    public static function instance(): AblyRest {
        if (self::$instance === null) {
            $apiKey = getenv('ABLY_API_KEY');
 
            if (empty($apiKey)) {
                throw new \Exception('ABLY_API_KEY environment variable is not set');
            }
 
            self::$instance = new AblyRest(['key' => $apiKey]);
        }
 
        return self::$instance;
    }
 
    public static function publishCaseAssignment(
        int $caseId,
        ?int $practitionerId,
        ?int $previousPractitionerId = null,
        ?bool $counseling = null
    ): void {
        try {
            $ably = self::instance();
 
            // Determine event type for debugging/analytics
            $eventType = 'assigned';
            if ($practitionerId === null) {
                $eventType = 'unassigned';
            } elseif ($previousPractitionerId !== null && $previousPractitionerId !== $practitionerId) {
                $eventType = 'reassigned';
            }
 
            $payload = [
                'caseId' => $caseId,
                'practitionerId' => $practitionerId,
                'previousPractitionerId' => $previousPractitionerId,
                'timestamp' => time(),
                'eventType' => $eventType,
            ];
 
            // Add counseling flag if provided
            if ($counseling !== null) {
                $payload['counseling'] = $counseling;
            }
 
            $channel = $ably->channel('case-assignments');
            $channel->publish('case-assigned', $payload);
        } catch (\Exception $e) {
            // Log but don't fail the request
            error_log("Ably publish error: " . $e->getMessage());
        }
    }
}

Key design decisions:

  1. Singleton pattern - One Ably connection per request, reused if multiple publishes needed
  2. Fail-safe try/catch - Ably failures don't break the case update
  3. Counseling flag - Critical for frontend filtering (more on this later)

Controller Integration

The PatientCase controller hooks into Ably after a successful database update:

public function updateCase(): void {
    // ... validation and setup ...
 
    // Store previous practitionerId before update
    $previousPractitionerId = $u->practitionerId; 
 
    $data = array_intersect_key($user, $filters);
 
    // ... apply changes and save ...
 
    if ($res === false) {
        $this->json(['error' => 'Failed to update case'], 412);
    }
 
    // Publish to Ably if practitionerId was changed
    if (array_key_exists('practitionerId', $data)) { 
        $newPractitionerId = $data['practitionerId']; 
        if ($newPractitionerId != $previousPractitionerId) { 
            $counseling = isset($u->counseling) ? (bool)$u->counseling : null; 
            Ably::publishCaseAssignment( 
                (int)$params['id'], 
                $newPractitionerId !== null ? (int)$newPractitionerId : null, 
                $previousPractitionerId !== null ? (int)$previousPractitionerId : null, 
                $counseling 
            ); 
        } 
    } 
 
    $this->json($res->cast(), 200);
}
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:

// src/app/api/ably/auth/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Ably from 'ably';
import getUserLoggedIn from '@/utils/getUserLoggedIn';
 
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
 
export async function GET(_request: NextRequest) {
  try {
    const user = await getUserLoggedIn().catch((error) => {
      console.error('Failed to get logged in user:', error);
      return null;
    });
 
    if (!user?.userId) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }
 
    const apiKey = process.env.ABLY_API_KEY;
    if (!apiKey) {
      console.error('ABLY_API_KEY is not set');
      return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
    }
 
    const ably = new Ably.Rest({ key: apiKey });
    const tokenParams = {
      clientId: `user-${user.userId}`,
      capability: JSON.stringify({
        'case-*': ['subscribe', 'publish'],
        '*': ['subscribe'],
      }),
      ttl: TOKEN_TTL_MS,
    };
 
    const tokenRequest = await ably.auth.createTokenRequest(tokenParams);
    return NextResponse.json(tokenRequest);
  } catch (error) {
    console.error('Ably token generation error:', error);
    return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 });
  }
}

Provider Setup

The Ably provider wraps the application, configured with the auth endpoint:

// src/components/providers/AblyProvider.tsx
'use client';
 
import * as Ably from 'ably';
import { AblyProvider as AblyReactProvider } from 'ably/react';
import { ReactNode, useMemo } from 'react';
 
export default function AblyProvider({ children }: { children: ReactNode }) {
  const client = useMemo(() => {
    return new Ably.Realtime({
      authUrl: '/api/ably/auth',
      authMethod: 'GET',
    });
  }, []);
 
  return (
    <AblyReactProvider client={client}>
      {children}
    </AblyReactProvider>
  );
}

Component Subscriptions

Each component that needs real-time updates creates its own listener using the useChannel hook:

// CaseTable - removes cases from list when taken
const CaseAssignmentListener = () => {
  useChannel('case-assignments', (message: any) => {
    if (message.name === 'case-assigned') {
      const { caseId, practitionerId, previousPractitionerId, counseling } = message.data;
 
      // Only update for non-counseling cases // [!code highlight]
      if (counseling === true) return; // [!code highlight]
 
      if (practitionerId !== null) {
        setCaseList((prev) => {
          const updated = prev.filter((c) => c.caseId !== caseId);
 
          // Toast if case was taken by another user
          if (updated.length < prev.length && practitionerId !== currentUser) {
            showToast({
              message: 'Ett ärende har tagits av en annan användare.',
              type: 'info',
            });
          }
 
          return updated;
        });
      }
 
      // Update tab counts...
    }
  });
  return null;
};

The Counseling Edge Case

Here's where things got interesting. Doktera has two case types:

  1. Regular cases - Standard medical consultations
  2. Counseling cases - 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:

// In CaseTable, CaseTableMenu, etc.
if (counseling === true) return;

And in AdminSideMenu, which handles both counters:

if (counseling === true) {
  // Update counseling count
  setUnassignedCounseling((prev) => {
    if (prev === undefined) return prev;
    if (previousPractitionerId === null && practitionerId !== null) {
      return Math.max(0, prev - 1);
    }
    if (previousPractitionerId !== null && practitionerId === null) {
      return prev + 1;
    }
    return prev;
  });
} else {
  // Update regular cases count
  setUnassignedCases((prev) => {
    // ... same logic
  });
}

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 3 seconds.

The existing toast system only supported auto-dismissing toasts:

// Before
const showToast = ({ message, type }: Omit<Toast, 'id'>) => {
  const id = Date.now();
  setToasts((prev) => [...prev, { id, message, type }]);
 
  setTimeout(() => {
    setToasts((prev) => prev.filter((t) => t.id !== id));
  }, 3000); // Always auto-dismiss
};

We extended it with a persistent option:

interface Toast {
  id: number;
  message: string;
  type: ToastType;
  persistent?: boolean; 
}
 
const showToast = ({ message, type, persistent = false }: Omit<Toast, 'id'>) => {
  const id = Date.now();
  setToasts((prev) => [...prev, { id, message, type, persistent }]);
 
  if (!persistent) { 
    setTimeout(() => {
      setToasts((prev) => prev.filter((t) => t.id !== id));
    }, 3000);
  } 
};

With accompanying CSS for the close button and a fade-in-only animation:

@keyframes fadeIn {
  0% { opacity: 0; transform: translateY(-10px); }
  100% { opacity: 1; transform: translateY(0); }
}
 
.persistent {
  animation: fadeIn 0.3s ease forwards;
}
 
.closeButton {
  display: flex;
  align-items: center;
  background: transparent;
  border: none;
  cursor: pointer;
  padding: 4px;
  margin-left: 8px;
  opacity: 0.7;
  transition: opacity 0.2s ease;
 
  &:hover {
    opacity: 1;
  }
}

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 if (array_key_exists('practitionerId', $data)) ensures:

  • Carasent close flow: Only sends closedAt - Ably not triggered
  • Case update flow: May send various fields - Ably only triggers if practitionerId included
  • Move case flow: Sends practitionerId - Ably triggers correctly

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:
├── app/Services/Ably.php (publishCaseAssignment only)
├── app/Controllers/PatientCase.php (assignment hooks)
└── composer.json (ably/ably-php dependency)

Reverted:
├── app/Controllers/Message.php
└── app/Controllers/Attachment.php

The message-related methods (publishMessage, prepareMessageForAbly, publishMessageData) were removed from the Ably service - they can be added back when the chat real-time feature is properly scoped.

Files Modified

Backend (doktera-mina-sidor-backend-external):
├── app/Services/Ably.php           (new)
├── app/Controllers/PatientCase.php (modified - Ably hooks)
└── composer.json                   (ably/ably-php dependency)

Frontend (doktera-frontend):
├── src/app/api/ably/auth/route.ts              (new)
├── src/components/providers/AblyProvider.tsx   (new)
├── src/lib/ably.ts                             (new)
├── src/app/layout.tsx                          (AblyProvider wrapper)
├── src/components/layout/CaseTable/index.tsx   (subscription + counseling fix)
├── src/components/core/CaseTableMenu/index.tsx (subscription + counseling fix)
├── src/components/core/Menu/SideMenu/admin/admin.tsx (dual counter subscription)
├── src/components/page/authed/arenden/mina-arenden/[id]/pageClient.tsx (warning toast)
├── src/components/core/Toaster/index.tsx       (persistent option)
└── src/components/core/Toaster/Toast.module.scss (persistent styles)

Takeaways

  1. Backend as sole publisher - Simplifies security model. Frontend only subscribes, never publishes. No need to scope channel capabilities per-user for case assignments.

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

  3. 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.

  4. 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.

  5. UX details - The persistent toast seems minor but matters 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.