- Appointment Master page with status tabs, search, PhoneActionCell - Login calls DataProvider.refresh() to load data after auth - Sidebar: appointments nav for CC agents + executives - Multi-agent SIP + lockout spec and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6.0 KiB
Multi-Agent SIP Credentials + Duplicate Login Lockout
Date: 2026-03-23 Status: Approved design
Problem
Single Ozonetel agent account (global) and SIP extension (523590) shared across all CC agents. When multiple agents log in, calls route to whichever browser registered last. No way to have multiple simultaneous CC agents.
Solution
Per-agent Ozonetel credentials stored in the platform's Agent entity, resolved on login. Redis-backed session locking prevents duplicate logins. Frontend SIP provider uses dynamic credentials from login response.
1. Data Model
Agent entity (already created on platform via admin portal):
| Field (GraphQL) | Type | Purpose |
|---|---|---|
wsmemberId |
Relation | Links to workspace member |
ozonetelagentid |
Text | Ozonetel agent ID (e.g. "global", "agent2") |
sipextension |
Text | SIP extension number (e.g. "523590") |
sippassword |
Text | SIP auth password |
campaignname |
Text | Ozonetel campaign (e.g. "Inbound_918041763265") |
Custom fields use all-lowercase GraphQL names. One Agent record per CC user.
2. Sidecar Changes
2.1 Redis Integration
Add ioredis dependency to helix-engage-server. Connect to REDIS_URL (default redis://redis:6379).
New service: src/auth/session.service.ts
lockSession(agentId, memberId) → SET agent:session:{agentId} {memberId} EX 3600
isSessionLocked(agentId) → GET agent:session:{agentId} → returns memberId or null
refreshSession(agentId) → EXPIRE agent:session:{agentId} 3600
unlockSession(agentId) → DEL agent:session:{agentId}
2.2 Auth Controller — Login Flow
Modify POST /auth/login:
- Authenticate with platform → get JWT + user profile + workspace member ID
- Determine role (same as today)
- If CC agent:
a. Query platform:
agents(filter: { wsmemberId: { eq: "<memberId>" } })using server API key b. No Agent record →403: "Agent account not configured. Contact administrator."c. Check Redis:isSessionLocked(agent.ozonetelagentid)d. Locked by different user →409: "You are already logged in on another device. Please log out there first."e. Locked by same user → refresh TTL (re-login from same browser) f. Not locked →lockSession(agent.ozonetelagentid, memberId)g. Login to Ozonetel with agent's specific credentials h. ReturnagentConfigin response - If manager/executive: No Agent query, no Redis, no SIP. Same as today.
Login response (CC agent):
{
"accessToken": "...",
"refreshToken": "...",
"user": { "id": "...", "role": "cc-agent", ... },
"agentConfig": {
"ozonetelAgentId": "global",
"sipExtension": "523590",
"sipPassword": "523590",
"sipUri": "sip:523590@blr-pub-rtc4.ozonetel.com",
"sipWsServer": "wss://blr-pub-rtc4.ozonetel.com:444",
"campaignName": "Inbound_918041763265"
}
}
SIP domain (blr-pub-rtc4.ozonetel.com) and WS port (444) remain from env vars — these are shared infrastructure, not per-agent.
2.3 Auth Controller — Logout
Modify POST /auth/logout (or add if doesn't exist):
- Resolve agent from JWT
unlockSession(agent.ozonetelagentid)- Ozonetel agent logout
2.4 Auth Controller — Heartbeat
New endpoint: POST /auth/heartbeat
- Resolve agent from JWT
refreshSession(agent.ozonetelagentid)→ extends TTL to 1 hour- Return
{ status: 'ok' }
2.5 Agent Config Cache
On login, store agent config in an in-memory Map<workspaceMemberId, AgentConfig>.
All Ozonetel controller endpoints currently use this.defaultAgentId. Change to:
- Resolve workspace member from JWT (already done in worklist controller's
resolveAgentName) - Lookup agent config from the in-memory map
- Use the agent's
ozonetelagentidfor Ozonetel API calls
This avoids querying Redis/platform on every API call.
Clear the cache entry on logout.
2.6 Config
New env var: REDIS_URL (default: redis://redis:6379)
Existing env vars (OZONETEL_AGENT_ID, OZONETEL_SIP_ID, etc.) become fallbacks only — used when no Agent record exists (backward compatibility for dev).
3. Frontend Changes
3.1 Store Agent Config
On login, store agentConfig from the response in localStorage (helix_agent_config).
On logout, clear it.
3.2 SIP Provider
sip-provider.tsx: Read SIP credentials from stored agentConfig instead of env vars.
const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config'));
const sipUri = agentConfig?.sipUri ?? import.meta.env.VITE_SIP_URI;
const sipPassword = agentConfig?.sipPassword ?? import.meta.env.VITE_SIP_PASSWORD;
const sipWsServer = agentConfig?.sipWsServer ?? import.meta.env.VITE_SIP_WS_SERVER;
If no agentConfig and no env vars → don't connect SIP.
3.3 Heartbeat
Add a heartbeat interval in AppShell (only for CC agents):
- Every 5 minutes:
POST /auth/heartbeat - If heartbeat fails with 401 → session expired, redirect to login
3.4 Login Error Handling
Handle new error codes from login:
403→ "Agent account not configured. Contact administrator."409→ "You are already logged in on another device. Please log out there first."
3.5 Logout
On logout, call POST /auth/logout before clearing tokens (so sidecar can clean up Redis + Ozonetel).
4. Docker Compose
Add REDIS_URL to sidecar environment in docker-compose.yml:
sidecar:
environment:
REDIS_URL: redis://redis:6379
5. Edge Cases
- Sidecar restart: Redis retains session locks. Agent config cache is lost but rebuilt on next API call (query Agent entity lazily).
- Redis restart: All session locks cleared. Agents can re-login. Acceptable — same as TTL expiry.
- Browser crash (no logout): Heartbeat stops → Redis key expires in ≤1 hour → lock clears.
- Same user, same browser re-login: Detected by comparing
memberIdin Redis → refreshes TTL instead of blocking. - Agent record deleted while logged in: Next Ozonetel API call fails → sidecar clears cache → agent gets logged out.