Skip to main content
Version: MyBestDealNow

Real-Time Chat with SignalR

Author(s)

  • Sanket Mal

Last Updated Date

2025-12-12


SRS References


Version History

VersionDateChangesAuthor
1.02025-11-19Implement SignalR into existing Chat system (Hybrid Architecture : SignalR + HTTP)Sanket Mal

Feature Overview

Objective:
Implement a hybrid real-time communication system using SignalR + HTTP that supports instant messaging between dealers and customers with read receipts, typing indicators, and real-time conversation updates.

Scope:

  • Real-time message delivery with SignalR WebSocket connections
  • HTTP endpoints for reliable message sending and read operations
  • Typing indicators and read receipts
  • Multi-user and multi-device support
  • Privacy-aware customer data handling (contact info hidden until dealer wins)
  • Automatic fallback to SSE/Long Polling if WebSocket unavailable

Dependencies:

  • ASP.NET Core SignalR
  • Microsoft.AspNetCore.SignalR.Protocols.MessagePack
  • Existing JWT authentication system
  • PostgreSQL database
  • MassTransit for inter-service communication

Requirements

  1. Real-time message delivery between dealers and customers
  2. Support for multiple dealer users per dealer entity
  3. Support for multiple devices per user (web + mobile)
  4. Real-time read receipts (blue ticks)
  5. Real-time typing indicators
  6. Backward compatibility with existing HTTP chat APIs
  7. Secure authentication using JWT tokens
  8. Privacy enforcement (customer contact info only visible to winning dealers)
  9. Message delivery confirmation
  10. Automatic reconnection on network interruption

Design Specifications

UI/UX Design

  • Chat Window:
    • Instant message appearance
    • Read receipts (double blue tick when read, single grey tick when delivered)
    • "User is typing..." indicator
    • Threaded replies with visual indentation
  • Conversation List:
    • Last message preview
    • Unread badge count
    • Auto-sort (new messages move conversation to top)
  • Connection Status:
    • Green indicator when connected
    • "Reconnecting..." yellow indicator
    • "Connection lost" red indicator with retry

Data Models

1. Message

public record Message
{
public Guid MessageId { get; init; }
public Guid ConversationId { get; init; }
public Guid SenderId { get; init; } // UserId
public string SenderName { get; init; }
public Guid SenderEntityId { get; init; } // DealerId or CustomerId
public UserType SenderType { get; init; } // Dealer/Customer
public string MessageText { get; init; }
public ReplyToMessage? ReplyToMessage { get; init; }
public List<string>? Attachments { get; init; }
public bool IsDelivered { get; init; }
public bool IsSeen { get; init; }
public DateTime? SeenAt { get; init; }
public string EntityName { get; init; }
public string EntityImage { get; init; }
public DateTime CreatedAt { get; init; }
}

Frontend receives this in: ReceiveMessage SignalR event

Example JSON Response:

{
"messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"conversationId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderName": "John Doe",
"senderEntityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderType": 2,
"messageText": "We can offer a 5% discount on this vehicle",
"replyToMessage": null,
"attachments": [],
"isDelivered": true,
"isSeen": false,
"seenAt": null,
"entityName": "ABC Motors",
"entityImage": "https://cdn.example.com/dealer-logo.jpg",
"createdAt": "2025-12-03T10:30:00Z"
}

2. DealerConversation

public record DealerConversation
{
public Guid ConversationId { get; init; }
public Guid DealerId { get; init; }
public Guid AuctionId { get; init; }
public string AuctionCode { get; init; }

// ⚠️ PRIVACY: These are NULL unless IsWon = true
public Guid? CustomerId { get; init; }
public string? CustomerName { get; init; }
public string? CustomerEmail { get; init; }
public string? CustomerPhone { get; init; }

public bool IsWon { get; init; }
public string LastMessage { get; init; }
public int UnreadCount { get; init; }
public string? LastMessageSentBy { get; init; }
public DateTime UpdatedAt { get; init; }
}

Frontend receives this in:

  • HTTP GET /chat/dealer/conversations
  • SignalR ConversationUpdated event (for dealers)

Example JSON Response (Non-Winning Dealer):

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": null,
"customerName": null,
"customerEmail": null,
"customerPhone": null,
"isWon": false,
"lastMessage": "Thank you for your offer",
"unreadCount": 2,
"lastMessageSentBy": "Customer",
"updatedAt": "2025-12-03T10:35:00Z"
}

Example JSON Response (Winning Dealer):

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerName": "Jane Smith",
"customerEmail": "jane.smith@example.com",
"customerPhone": "+1234567890",
"isWon": true,
"lastMessage": "When can we finalize the deal?",
"unreadCount": 0,
"lastMessageSentBy": "Jane Smith",
"updatedAt": "2025-12-03T11:00:00Z"
}

3. CustomerConversation

public record CustomerConversation
{
public Guid ConversationId { get; init; }
public Guid CustomerId { get; init; }
public Guid DealerId { get; init; }
public string DealerName { get; init; }
public string DealerEmail { get; init; }
public string DealerPhone { get; init; }
public string Image { get; init; }
public bool IsWon { get; init; }
public string LastMessage { get; init; }
public int UnreadCount { get; init; }
public Guid AuctionId { get; init; }
public string AuctionCode { get; init; }
public DateTime UpdatedAt { get; init; }
}

Frontend receives this in:

  • HTTP GET /chat/customer/auction-rooms/{auctionId}/conversations
  • SignalR ConversationUpdated event (for customers)

Example JSON Response:

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerName": "ABC Motors",
"dealerEmail": "sales@abcmotors.com",
"dealerPhone": "+1987654321",
"image": "https://cdn.example.com/dealer-logo.jpg",
"isWon": true,
"lastMessage": "We can offer a 5% discount",
"unreadCount": 1,
"auctionId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"updatedAt": "2025-12-03T10:30:00Z"
}

4. SignalR Event Data

ReadReceiptEventData - When messages are marked as read

public record ReadReceiptEventData
{
public Guid ConversationId { get; init; }
public Guid ReadByUserId { get; init; }
public Guid ReadByEntityId { get; init; }
public UserType ReadByUserType { get; init; }
public DateTime ReadAt { get; init; }
}

Frontend receives this in: MessageRead SignalR event

Example JSON:

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByUserId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByEntityId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByUserType": 4,
"readAt": "2025-12-03T10:40:00Z"
}

TypingIndicatorEventData - When user starts/stops typing

public record TypingIndicatorEventData
{
public Guid ConversationId { get; init; }
public Guid UserId { get; init; }
public string UserName { get; init; }
public Guid EntityId { get; init; }
public string? EntityName { get; init; }
public UserType UserType { get; init; }
public bool IsTyping { get; init; }
}

Frontend receives this in: TypingIndicator SignalR event

Example JSON (Typing):

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userName": "John Doe",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityName": "ABC Motors",
"userType": 2,
"isTyping": true
}

Example JSON (Stopped Typing):

{
"conversationId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userName": "John Doe",
"entityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityName": "ABC Motors",
"userType": 2,
"isTyping": false
}

5. Enums

public enum UserType
{
Admin = 1,
Dealer = 2,
Customer = 4
}

public enum ConversationStatus
{
Active = 1,
Disable = 2
}

public enum ConversationUpdateType
{
NewMessage = 1,
StatusChanged = 2,
MessageRead = 3
}

API Interfaces

HTTP Endpoints

EndpointMethodParametersRequest BodyResponseStatus Codes
/chat/messagePOSTJWT in headerSendMessageRequest{ "messageId": "guid" }200, 400, 401, 403, 500
/chat/conversations/{id}/mark-readPUTid (Guid): Conversation ID
JWT in header
None{ "status": 200, "message": "Marked as read" }200, 401, 403, 404, 500
/chat/dealer/conversationsGETlimit, nextCursor, previousCursor, auctionId, status
JWT in header
NoneCursorPaginatedData<DealerConversation>200, 400, 401, 500
/chat/customer/auction-roomsGETlimit, nextCursor, previousCursor, status
JWT in header
NoneCursorPaginatedData<AuctionChatRoom>200, 401, 500
/chat/customer/auction-rooms/{id}/conversationsGETid (Guid): Auction ID
limit, nextCursor, previousCursor
JWT in header
NoneCursorPaginatedData<CustomerConversation>200, 401, 500
/chat/conversations/{id}/messagesGETid (Guid): Conversation ID
limit, nextCursor, previousCursor, searchKeyword
JWT in header
NoneCursorPaginatedData<Message>200, 401, 403, 404, 500

1. Send Message

Frontend sends:

POST /chat/message
Authorization: Bearer {JWT_TOKEN}
Content-Type: application/json

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"message": "We can offer a 5% discount on this vehicle",
"replyToMessageId": null,
"attachments": []
}

Backend responds:

{
"status": 200,
"messageId": "msg-abc-def-123"
}

Then SignalR automatically broadcasts ReceiveMessage event to all participants with full message object (see Message example above).


2. Mark as Read

Frontend sends:

PUT /chat/conversations/conv-1234-5678/mark-read
Authorization: Bearer {JWT_TOKEN}

Backend responds:

{
"status": 200,
"message": "Messages marked as read successfully"
}

Then SignalR automatically broadcasts:

  1. MessageRead event with ReadReceiptEventData
  2. ConversationUpdated event with updated UnreadCount = 0

3. Get Dealer Conversations

Frontend sends:

GET /chat/dealer/conversations?limit=20&auctionId=auction-abc-def&status=1
Authorization: Bearer {JWT_TOKEN}

Backend responds:

{
"data": [
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": null,
"customerName": null,
"customerEmail": null,
"customerPhone": null,
"isWon": false,
"lastMessage": "Thank you for your offer",
"unreadCount": 2,
"lastMessageSentBy": "Customer",
"updatedAt": "2025-12-03T10:35:00Z"
}
],
"nextCursor": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"previousCursor": null,
"hasMore": true
}

4. Get Messages

Frontend sends:

GET /chat/conversations/conv-1234-5678/messages?limit=50
Authorization: Bearer {JWT_TOKEN}

Backend responds:

{
"data": [
{
"messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"conversationId": "bb2c3d4-e5f6-7890-abcd-ef1234567890",
"senderId": "u1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderName": "John Doe",
"senderEntityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderType": 2,
"messageText": "Hello, I'm interested in this vehicle",
"replyToMessage": null,
"attachments": [],
"isDelivered": true,
"isSeen": true,
"seenAt": "2025-12-03T10:40:00Z",
"entityName": "ABC Motors",
"entityImage": "https://cdn.example.com/logo.jpg",
"createdAt": "2025-12-03T10:30:00Z"
}
],
"nextCursor": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"previousCursor": null,
"hasMore": true
}

SignalR Hub

Hub Endpoint: /realtimehub
Authentication: JWT token via query string ?access_token={JWT_TOKEN}

Client → Server Hub Methods

These are methods frontend can invoke on the hub:

MethodParametersDescriptionWhen to Call
StartTypingconversationId (Guid)Notify others user is typingWhen user starts typing in input field
StopTypingconversationId (Guid)Notify others user stopped typingAfter 2-3 seconds of no typing OR when message is sent
GetInitialPresenceDataNoneFetch presence data for all conversationsAfter component mounts (if connection already established)
HeartbeatNoneRefresh connection TTL in Valkey (prevents auto-disconnect)Every 2 minutes via setInterval

Frontend invokes:

// User starts typing
connection.invoke("StartTyping", "a1b2c3d4-e5f6-7890-abcd-ef1234567890");

// User stops typing (debounced)
connection.invoke("StopTyping", "a1b2c3d4-e5f6-7890-abcd-ef1234567890");

// Request presence data (when chat component mounts)
connection.invoke("GetInitialPresenceData");

// Keep connection alive (heartbeat)
setInterval(() => {
connection.invoke("Heartbeat");
}, 120000); // 2 minutes

Server → Client Events

These are events frontend listens for:

Event NamePayload TypeDescriptionWhen Received
ReceiveMessageMessageNew message in conversationAfter any participant sends a message via HTTP
MessageReadReadReceiptEventDataMessages marked as readAfter any participant marks messages as read via HTTP
TypingIndicatorTypingIndicatorEventDataUser typing statusWhen another user invokes StartTyping/StopTyping
ConversationUpdatedDealerConversationUpdateEventData or CustomerConversationUpdateEventDataConversation metadata changedAfter new message or read receipt
InitialPresenceDataEntityPresenceEventData[]Presence status for all conversationsOn connection OR after invoking GetInitialPresenceData hub method
EntityPresenceChangedEntityPresenceEventDataEntity went online/offlineWhen any conversation participant connects/disconnects from SignalR

WebSocket Response Examples by Scenario

Scenario 1: User Sends a Message

When a user sends a message via HTTP POST, they receive 2 WebSocket events:

Event 1: ReceiveMessage - Broadcasts to all conversation participants

{
"messageId": "f1b2c3d4-e5f6-7890-abcd-ef1234567890",
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"senderName": "John Doe",
"senderType": 4,
"text": "I would like to make an offer",
"attachments": null,
"sentAt": "2025-12-03T10:30:00Z",
"isRead": false,
"readAt": null
}

Event 2: ConversationUpdated - Broadcasts to entities (dealer or customer based on recipient)

For Dealers:

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"updateType": 1,
"updatedAt": "2025-12-03T10:35:00Z",
"conversation": {
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": null,
"customerName": null,
"customerEmail": null,
"customerPhone": null,
"isWon": false,
"lastMessage": "I would like to make an offer",
"unreadCount": 1,
"lastMessageSentBy": "Customer",
"updatedAt": "2025-12-03T10:35:00Z"
}
}

For Customers:

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"updateType": 1,
"updatedAt": "2025-12-03T10:35:00Z",
"conversation": {
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerName": "ABC Motors",
"dealerEmail": "sales@abcmotors.com",
"dealerPhone": "+1987654321",
"image": "https://cdn.example.com/dealer-logo.jpg",
"isWon": false,
"lastMessage": "I would like to make an offer",
"unreadCount": 1,
"lastMessageSentBy": "ABC Motors",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"updatedAt": "2025-12-03T10:35:00Z"
}
}

Scenario 2: User Marks Messages as Read

When a user marks messages as read via HTTP PUT, they receive 2 WebSocket events:

Event 1: MessageRead - Broadcasts to all conversation participants

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByUserId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByEntityId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"readByUserType": 4,
"readAt": "2025-12-03T10:40:00Z"
}

Event 2: ConversationUpdated - Broadcasts to entities with updated unread count

For Dealers:

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"updateType": 3,
"updatedAt": "2025-12-03T10:40:00Z",
"conversation": {
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"customerId": null,
"customerName": null,
"customerEmail": null,
"customerPhone": null,
"isWon": false,
"lastMessage": "Thank you for your offer",
"unreadCount": 0,
"lastMessageSentBy": "Customer",
"updatedAt": "2025-12-03T10:40:00Z"
}
}

For Customers:

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"updateType": 3,
"updatedAt": "2025-12-03T10:40:00Z",
"conversation": {
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dealerName": "ABC Motors",
"dealerEmail": "sales@abcmotors.com",
"dealerPhone": "+1987654321",
"image": "https://cdn.example.com/dealer-logo.jpg",
"isWon": false,
"lastMessage": "Thank you for your offer",
"unreadCount": 0,
"lastMessageSentBy": "ABC Motors",
"auctionId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"auctionCode": "AUC-2025-001",
"updatedAt": "2025-12-03T10:40:00Z"
}
}

Scenario 3: User Starts Typing

When a user calls StartTyping(conversationId) via SignalR hub, other participants receive 1 WebSocket event:

Event: TypingIndicator - Broadcasts to all conversation participants except the sender

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userName": "John Doe",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityName": "ABC Motors",
"userType": 2,
"isTyping": true
}

Scenario 4: User Stops Typing

When a user calls StopTyping(conversationId) via SignalR hub, other participants receive 1 WebSocket event:

Event: TypingIndicator - Broadcasts to all conversation participants except the sender

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userName": "John Doe",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityName": "ABC Motors",
"userType": 2,
"isTyping": false
}

Scenario 5: User Comes Online (Connects to SignalR)

When a user connects to the SignalR hub, the system tracks their presence and broadcasts to other conversation participants:

Event 1: InitialPresenceData - Sent ONLY to the newly connected user (all conversations at once)

[
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 4,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:20:00Z"
},
{
"conversationId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "e1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": false,
"lastStatusChangeTimestamp": "2025-12-03T09:45:00Z"
}
]

Event 2: EntityPresenceChanged - Broadcasts to OTHER participants in all conversations where this entity participates

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:30:00Z"
}

Scenario 6: User Goes Offline (Disconnects from SignalR)

When a user disconnects (closes browser, network loss, logout), the system detects it and broadcasts:

Event: EntityPresenceChanged - Broadcasts to OTHER participants in all conversations where this entity participates

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": false,
"lastStatusChangeTimestamp": "2025-12-03T11:45:00Z"
}

Scenario 7: Frontend Requests Presence Data (Component Mounted Late)

When frontend component mounts after the SignalR connection is already established (e.g., navigating to chat page), it can request presence data:

Frontend invokes:

await connection.invoke("GetInitialPresenceData");

Backend responds with Event: InitialPresenceData - Sent ONLY to the caller

[
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 4,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:20:00Z"
},
{
"conversationId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "e1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": false,
"lastStatusChangeTimestamp": "2025-12-03T09:45:00Z"
}
]

Entity Presence Tracking (Online/Offline Indicators)

Overview:
The system tracks real-time presence status of all entities (dealers and customers) using Valkey (Redis-compatible) for distributed state management. When users connect/disconnect from SignalR, their presence is tracked and broadcast to all conversation participants.

Key Features:

  • Entity-Level Tracking: Presence is tracked per entity (dealer/customer), not per user. If multiple dealer users from the same dealership are online, the dealer entity shows as "online"
  • Multi-Device Support: Entity remains online as long as ANY user/device is connected. Goes offline only when ALL connections are closed
  • Connection-Level Granularity: Each WebSocket connection is tracked individually with metadata (entityId, userId, connectionId)
  • Automatic Cleanup: Valkey TTL (Time-To-Live) ensures stale connections are cleaned up if heartbeat fails
  • Timestamp Tracking: Stores LastStatusChangeTimestamp in Valkey to show "offline 29 minutes ago" or "online since 10:30 AM"

Valkey (Redis) Data Structures

1. Entity Connections Set - Tracks all active connections for an entity

Key: entity:{entityId}:connections
Type: Set<string>
Value: ["connectionId1", "connectionId2", ...]
TTL: 5 minutes (refreshed by heartbeat)

Example:
entity:d1b2c3d4-e5f6-7890-abcd-ef1234567890:connections
= {"conn-abc-123", "conn-def-456"}

2. Connection Metadata Hash - Stores metadata for reverse lookup

Key: connection:{connectionId}:metadata
Type: Hash
Fields: entityId, userId, connectedAt
TTL: 5 minutes (refreshed by heartbeat)

Example:
connection:conn-abc-123:metadata
= {
entityId: "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
userId: "u1b2c3d4-e5f6-7890-abcd-ef1234567890",
connectedAt: "2025-12-03T10:30:00Z"
}

3. Entity Last Status Change Timestamp - Tracks when entity went online/offline

Key: entity:{entityId}:last_status_change
Type: String (ISO 8601 DateTime)
TTL: 24 hours (persists longer than connections)

Example:
entity:d1b2c3d4-e5f6-7890-abcd-ef1234567890:last_status_change
= "2025-12-03T10:30:00Z"

Presence Tracking Workflow

Connection Flow (User Comes Online):

User Device                 RealTimeHub                 PresenceTracker              Valkey                Other Participants
| | | | |
| SignalR Connect | | | |
|-------------------------->| | | |
| | | | |
| | AddConnectionAsync() | | |
| |-------------------------->| | |
| | | | |
| | | SADD entity:connections | |
| | |-------------------------->| |
| | | | |
| | | Check: First connection? | |
| | | (SET size was 0, now 1) | |
| | | | |
| | | SET last_status_change | |
| | |-------------------------->| |
| | | | |
| | wentOnline = true | | |
| |<--------------------------| | |
| | | | |
| | GetConversationPresence() | | |
| |-------------------------->| | |
| | | | |
| | | Batch query all entities | |
| | |-------------------------->| |
| | | | |
| | InitialPresenceData | | |
| <InitialPresenceData> | | | |
|<--------------------------| | | |
| | | | |
| | BroadcastEntityPresence() | | |
| | (to all conversations) | | |
| |--------------------------------------------------------->| EntityPresenceChanged |
| | | | | {isOnline: true}
| | | | |

Disconnection Flow (User Goes Offline):

User Device                 RealTimeHub                 PresenceTracker              Valkey                Other Participants
| | | | |
| Disconnect | | | |
| (close tab/logout) | | | |
|-------------------------->| | | |
| | | | |
| | RemoveConnectionAsync() | | |
| |-------------------------->| | |
| | | | |
| | | SREM entity:connections | |
| | |-------------------------->| |
| | | | |
| | | Check: Last connection? | |
| | | (SET size now 0) | |
| | | | |
| | | SET last_status_change | |
| | |-------------------------->| |
| | | | |
| | wentOffline = true | | |
| |<--------------------------| | |
| | | | |
| | BroadcastEntityPresence() | | |
| | (to all conversations) | | |
| |--------------------------------------------------------->| EntityPresenceChanged |
| | | | | {isOnline: false}
| | | | |

Heartbeat Flow (Keep Connection Alive):

Frontend (Timer)           RealTimeHub                 PresenceTracker              Valkey
| | | |
| Every 2 minutes | | |
| invoke("Heartbeat") | | |
|-------------------------->| | |
| | | |
| | RefreshConnectionAsync() | |
| |-------------------------->| |
| | | |
| | | EXPIRE connections SET |
| | | (reset TTL to 5 min) |
| | |-------------------------->|
| | | |
| | | EXPIRE metadata HASH |
| | | (reset TTL to 5 min) |
| | |-------------------------->|
| | | |

Data Model for Presence

EntityPresenceEventData - Sent via SignalR events

public record EntityPresenceEventData
{
public Guid ConversationId { get; init; } // Which conversation this applies to
public Guid EntityId { get; init; } // Dealer/Customer/Admin ID
public UserType EntityType { get; init; } // Dealer = 2, Customer = 4
public bool IsOnline { get; init; } // true = online, false = offline
public DateTime? LastStatusChangeTimestamp { get; init; } // When they went online/offline
}

Frontend receives this in:

  1. InitialPresenceData event - Array of all conversation participants (sent on connect or on-demand)
  2. EntityPresenceChanged event - Single entity that just went online/offline

Example JSON Responses:

InitialPresenceData (Array) - Received on connection or via GetInitialPresenceData() hub method:

[
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "d1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 4,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:20:00Z"
},
{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "e1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": false,
"lastStatusChangeTimestamp": "2025-12-03T09:45:00Z"
},
{
"conversationId": "b1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "f1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 4,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:25:00Z"
}
]

EntityPresenceChanged (Single Object) - Received when someone goes online/offline:

{
"conversationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityId": "c1b2c3d4-e5f6-7890-abcd-ef1234567890",
"entityType": 2,
"isOnline": true,
"lastStatusChangeTimestamp": "2025-12-03T10:30:00Z"
}

Hub Methods for Presence

MethodParametersDescriptionWhen to Call
GetInitialPresenceDataNoneFetch presence data for all conversationsAfter component mounts (if connection already established)
HeartbeatNoneRefresh connection TTL in Valkey (prevents auto-disconnect)Every 2 minutes via setInterval

Privacy & Security Considerations

  • Entity-Level Broadcasting: Presence changes are broadcast ONLY to conversation participants, not globally
  • Sender Exclusion: Entity does not receive its own presence updates (GroupExcept pattern)
  • TTL-Based Cleanup: Stale connections automatically cleaned up after 5 minutes of no heartbeat
  • Fallback Mechanism: If context is unavailable during disconnect, Redis metadata lookup ensures cleanup

Performance Optimizations

  • Batch Queries: Initial presence data fetches all conversation participants in ONE Valkey query, not N queries
  • Connection Pooling: Valkey connection pool reuses connections across requests
  • Selective Broadcasting: Only broadcasts to specific conversation groups, not all connected users
  • TTL Refresh: Heartbeat method is lightweight (single Redis EXPIRE command per connection)

Third-Party Integrations

  • MassTransit: Inter-service communication for fetching dealer/customer names
  • PostgreSQL: Database for messages, conversations
  • MessagePack: Binary protocol for SignalR (30-50% smaller payloads than JSON)
  • Valkey (Redis-compatible): Distributed presence tracking for online/offline status, connection management with TTL-based cleanup

Workflow

1. Connection Flow

Frontend                          Backend (RealTimeHub)
| |
| Connect to /realtimehub |
| ?access_token={JWT} |
|------------------------------------->|
| |
| | Validate JWT
| | Extract: userId, userType, entityId
| |
| | Add to groups:
| | - User-{userId}
| | - Entity-{entityId}
| | - Conversation-{id1}
| | - Conversation-{id2}...
| |
| ✅ Connected |
|<-------------------------------------|
| |

2. Send Message Flow (Hybrid: HTTP + SignalR)

Sender                  ChatController              SignalR                Recipients
Frontend (HTTP) Hub Frontend
| | | |
| POST /chat/message | | |
|----------------------->| | |
| | | |
| | Validate & Save | |
| | to Database | |
| | | |
| HTTP 200 | | |
| {messageId} | | |
|<-----------------------| | |
| | | |
| (Show message in UI) | Broadcast | |
| | ReceiveMessage | |
| |----------------------->| |
| | | |
| | | ReceiveMessage event |
| | |----------------------->|
| | | |
| | | | (Show message)
| | | |
| | Broadcast | |
| | ConversationUpdated | |
| |----------------------->| |
| | | |
| | | ConversationUpdated |
| | |----------------------->|
| | | |
| | | | (Update list)

Key Points:

  • HTTP ensures message is saved to database (reliable)
  • SignalR provides instant delivery to all participants (real-time)
  • Sender gets HTTP confirmation immediately
  • Recipients get SignalR broadcast within milliseconds

3. Typing Indicator Flow

User 1                  SignalR Hub               User 2
Frontend Frontend
| | |
| User starts typing | |
| invoke("StartTyping") | |
|----------------------->| |
| | |
| | TypingIndicator |
| | {isTyping: true} |
| |----------------------->|
| | |
| | | Show "User 1 is typing..."
| | |
| User stops typing | |
| invoke("StopTyping") | |
|----------------------->| |
| | |
| | TypingIndicator |
| | {isTyping: false} |
| |----------------------->|
| | |
| | | Hide typing indicator

4. Mark as Read Flow

Customer                ChatController              SignalR                Dealer
Frontend (HTTP) Hub Frontend
| | | |
| PUT .../mark-read | | |
|----------------------->| | |
| | | |
| | Update database | |
| | (Mark dealer msgs | |
| | as read) | |
| | | |
| HTTP 200 | | |
|<-----------------------| | |
| | | |
| (Clear unread badge) | Broadcast | |
| | MessageRead | |
| |----------------------->| |
| | | |
| | | MessageRead event |
|<-----------------------|------------------------|----------------------->|
| | | |
| | | | (Show blue ticks)
| | | |
| | Broadcast | |
| | ConversationUpdated | |
| | (UnreadCount = 0) | |
| |----------------------->| |
| | | |
|<-----------------------|------------------------|----------------------->|
| | | |
| (Update conversation) | | | (Update conversation)

Development Tasks & Estimates

NoTask NameEstimate (Hours)Dependencies
1Configure SignalR in Program.cs with MessagePack2-
2Implement RealTimeHub with connection lifecycle4Task 1
3Auto-join users to conversation groups on connection4Task 2
4Implement SignalRBroadcastService5Task 2
5Broadcast ReceiveMessage after HTTP POST /chat/message3Task 4
6Broadcast MessageRead after HTTP PUT /mark-read3Task 4
7Implement StartTyping/StopTyping hub methods4Task 2
8Broadcast ConversationUpdated for last message preview3Task 5
9Privacy filtering for dealer conversations (IsWon check)3Task 8
10JWT authentication via query string for SignalR2Task 1
11Sender exclusion logic (GroupExcept for broadcasts)3Task 8
12Implement Valkey-based presence tracking (IEntityPresenceTracker)6Task 1
13Implement presence broadcasting (online/offline)5Task 12
14Implement GetInitialPresenceData hub method3Task 12, 13
15Implement Heartbeat method for connection TTL refresh2Task 12
16Integration testing and bug fixes10All tasks
Total62 hours

Testing & Quality Assurance

Unit Tests

  • JWT token validation in OnConnectedAsync
  • Group membership logic
  • Privacy filtering (IsWon check)
  • SignalRBroadcastService methods

Integration Tests

  • End-to-end message sending (HTTP → Database → SignalR → Recipients)
  • Read receipts propagation
  • Typing indicators across multiple users
  • Multi-device support (same user on 2 browsers)
  • Reconnection after network interruption

Acceptance Criteria

  • ✅ Messages delivered in < 500ms
  • ✅ Read receipts update in real-time across all devices
  • ✅ Typing indicators appear/disappear correctly
  • ✅ Privacy rules enforced (customer contact info hidden unless dealer won)
  • ✅ No duplicate messages
  • ✅ Automatic reconnection works
  • ✅ Conversation list updates in real-time
  • ✅ Online/offline indicators update instantly when users connect/disconnect
  • ✅ Presence data available on component mount (GetInitialPresenceData)
  • ✅ Multi-device presence works (entity stays online if ANY device connected)
  • ✅ Heartbeat keeps connection alive (TTL refresh every 2 minutes)
  • ✅ Stale connections cleaned up automatically after 5 minutes
  • ✅ LastStatusChangeTimestamp shows accurate "offline 29 minutes ago" text

Testing Tools

  • xUnit for backend unit tests
  • SignalR test framework for hub testing
  • Postman for HTTP endpoint testing
  • Browser DevTools for SignalR connection debugging

Deployment Considerations

Configuration Changes

Program.cs:

  • Add SignalR services with MessagePack protocol
  • Configure JWT authentication to accept tokens from query string
  • Configure CORS to allow credentials (required for SignalR)
  • Map SignalR hub endpoint /realtimehub
  • Configure Valkey (Redis) connection for presence tracking
  • Register IEntityPresenceTracker service with dependency injection

NuGet Packages:

  • Microsoft.AspNetCore.SignalR
  • Microsoft.AspNetCore.SignalR.Protocols.MessagePack
  • StackExchange.Redis (for Valkey connection)

Rollout Plan

  1. Deploy to staging environment
  2. Test with QA team (5-10 concurrent users)
  3. Monitor SignalR connection logs
  4. Gradual rollout to production (10% → 50% → 100%)
  5. Monitor performance metrics (connection count, message latency)

Risks & Mitigations

RiskImpactLikelihoodMitigation Strategy
WebSocket blocked by corporate firewallMediumLowSignalR automatic fallback to SSE/Long Polling
High concurrent connections overloadHighMediumImplement connection limits, load balancing
Message delivery failureHighLowHTTP ensures database persistence, retry logic
JWT token expiry during long sessionsMediumMediumToken refresh mechanism, graceful reconnection
Privacy leak (customer info exposed)HighLowIsWon check enforced before all dealer broadcasts
Duplicate messagesLowLowSender exclusion via GroupExcept, frontend deduplication

Review & Approval

  • Technical Reviewer:

  • Business Reviewer:

  • Approval Date:


Notes

  • Why Hybrid Architecture?: HTTP provides reliability and database persistence, while SignalR provides instant real-time delivery. Best of both worlds.

  • Privacy is Critical: Customer contact information (name, email, phone) is only visible to dealers who have won the auction (IsWon = true). This is enforced at multiple layers (DAL, Service, SignalR broadcasts).

  • Multi-User Support: Multiple dealer users from the same dealership can all see the same conversations. SignalR groups ensure all users receive updates.

  • Presence Tracking: Uses Valkey (Redis-compatible) for distributed state management. Entity-level tracking means a dealer entity shows "online" if ANY dealer user is connected. Automatic TTL-based cleanup prevents stale data.

  • Timing Issue Solution: The GetInitialPresenceData() hub method solves the race condition where presence data is sent automatically during connection but arrives before the frontend component is ready to store it. Frontend can now request presence data whenever needed.

  • Extensibility: This architecture is designed to support future features like real-time auction updates and live bidding system.

  • Frontend Integration: Frontend developers should connect to /realtimehub with JWT token, listen for the 6 main events (ReceiveMessage, MessageRead, TypingIndicator, ConversationUpdated, InitialPresenceData, EntityPresenceChanged), invoke StartTyping/StopTyping for typing indicators, invoke GetInitialPresenceData when component mounts, and set up a 2-minute heartbeat timer.