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 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:
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:
- Singleton pattern - One Ably connection per request, reused if multiple publishes needed
- Fail-safe try/catch - Ably failures don't break the case update
- 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);
}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:
- Regular cases - Standard medical consultations
- 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.
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?
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
practitionerIdincluded - 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
-
Backend as sole publisher - Simplifies security model. Frontend only subscribes, never publishes. No need to scope channel capabilities per-user for case assignments.
-
Fail-safe integration - The try/catch around Ably publishing ensures a third-party service failure doesn't break core functionality. Log it, move on.
-
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.
-
Preserve existing behavior - The
array_key_existscheck ensures we only publish when relevant. Touching a case for any other reason doesn't trigger spurious events. -
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.