feat(backend): Implement SignalR real-time communication infrastructure

Add complete SignalR infrastructure for real-time project collaboration and notifications with multi-tenant isolation and JWT authentication.

Changes:
- Created BaseHub with multi-tenant isolation and JWT authentication helpers
- Created ProjectHub for real-time project collaboration (join/leave, typing indicators)
- Created NotificationHub for user-level notifications
- Implemented IRealtimeNotificationService for application layer integration
- Configured SignalR in Program.cs with CORS and JWT query string support
- Added SignalRTestController for connection testing
- Documented hub endpoints, client events, and integration examples

Features:
- Multi-tenant isolation via automatic tenant group membership
- JWT authentication (Bearer header + query string for WebSocket)
- Hub endpoints: /hubs/project, /hubs/notification
- Project-level events: IssueCreated, IssueUpdated, IssueStatusChanged, etc.
- User-level notifications with tenant-wide broadcasting
- Test endpoints for validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-04 09:04:13 +01:00
parent 172d0de1fe
commit 5a1ad2eb97
8 changed files with 745 additions and 3 deletions

View File

@@ -0,0 +1,109 @@
using ColaFlow.API.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ColaFlow.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class SignalRTestController : ControllerBase
{
private readonly IRealtimeNotificationService _notificationService;
public SignalRTestController(IRealtimeNotificationService notificationService)
{
_notificationService = notificationService;
}
/// <summary>
/// Test sending notification to current user
/// </summary>
[HttpPost("test-user-notification")]
public async Task<IActionResult> TestUserNotification([FromBody] string message)
{
var userId = Guid.Parse(User.FindFirst("sub")!.Value);
await _notificationService.NotifyUser(userId, message, "test");
return Ok(new { message = "Notification sent", userId });
}
/// <summary>
/// Test sending notification to entire tenant
/// </summary>
[HttpPost("test-tenant-notification")]
public async Task<IActionResult> TestTenantNotification([FromBody] string message)
{
var tenantId = Guid.Parse(User.FindFirst("tenant_id")!.Value);
await _notificationService.NotifyUsersInTenant(tenantId, message, "test");
return Ok(new { message = "Tenant notification sent", tenantId });
}
/// <summary>
/// Test sending project update
/// </summary>
[HttpPost("test-project-update")]
public async Task<IActionResult> TestProjectUpdate([FromBody] TestProjectUpdateRequest request)
{
var tenantId = Guid.Parse(User.FindFirst("tenant_id")!.Value);
await _notificationService.NotifyProjectUpdate(tenantId, request.ProjectId, new
{
Message = request.Message,
UpdatedBy = User.FindFirst("sub")!.Value,
Timestamp = DateTime.UtcNow
});
return Ok(new { message = "Project update sent", projectId = request.ProjectId });
}
/// <summary>
/// Test sending issue status change
/// </summary>
[HttpPost("test-issue-status-change")]
public async Task<IActionResult> TestIssueStatusChange([FromBody] TestIssueStatusChangeRequest request)
{
var tenantId = Guid.Parse(User.FindFirst("tenant_id")!.Value);
await _notificationService.NotifyIssueStatusChanged(
tenantId,
request.ProjectId,
request.IssueId,
request.OldStatus,
request.NewStatus
);
return Ok(new
{
message = "Issue status change notification sent",
projectId = request.ProjectId,
issueId = request.IssueId
});
}
/// <summary>
/// Get connection info for debugging
/// </summary>
[HttpGet("connection-info")]
public IActionResult GetConnectionInfo()
{
return Ok(new
{
userId = User.FindFirst("sub")?.Value,
tenantId = User.FindFirst("tenant_id")?.Value,
roles = User.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToList(),
hubEndpoints = new[]
{
"/hubs/project",
"/hubs/notification"
},
instructions = "Connect to SignalR hubs using the endpoints above with access_token query parameter"
});
}
}
public record TestProjectUpdateRequest(Guid ProjectId, string Message);
public record TestIssueStatusChangeRequest(Guid ProjectId, Guid IssueId, string OldStatus, string NewStatus);

View File

@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ColaFlow.API.Hubs;
[Authorize] // All Hubs require authentication
public abstract class BaseHub : Hub
{
protected Guid GetCurrentUserId()
{
var userIdClaim = Context.User?.FindFirst("sub")
?? Context.User?.FindFirst("user_id");
if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId))
{
throw new UnauthorizedAccessException("User ID not found in token");
}
return userId;
}
protected Guid GetCurrentTenantId()
{
var tenantIdClaim = Context.User?.FindFirst("tenant_id");
if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
{
throw new UnauthorizedAccessException("Tenant ID not found in token");
}
return tenantId;
}
protected string GetTenantGroupName(Guid tenantId)
{
return $"tenant-{tenantId}";
}
public override async Task OnConnectedAsync()
{
try
{
var tenantId = GetCurrentTenantId();
var userId = GetCurrentUserId();
// Automatically join tenant group (tenant isolation)
await Groups.AddToGroupAsync(Context.ConnectionId, GetTenantGroupName(tenantId));
// Log connection
Console.WriteLine($"User {userId} from tenant {tenantId} connected. ConnectionId: {Context.ConnectionId}");
await base.OnConnectedAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Connection error: {ex.Message}");
Context.Abort();
}
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var tenantId = GetCurrentTenantId();
var userId = GetCurrentUserId();
Console.WriteLine($"User {userId} from tenant {tenantId} disconnected. Reason: {exception?.Message ?? "Normal"}");
await base.OnDisconnectedAsync(exception);
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.SignalR;
namespace ColaFlow.API.Hubs;
/// <summary>
/// Notification Hub (user-level notifications)
/// </summary>
public class NotificationHub : BaseHub
{
/// <summary>
/// Mark notification as read
/// </summary>
public async Task MarkAsRead(Guid notificationId)
{
var userId = GetCurrentUserId();
// TODO: Call Application layer to mark notification as read
await Clients.Caller.SendAsync("NotificationRead", new
{
NotificationId = notificationId,
ReadAt = DateTime.UtcNow
});
}
}

View File

@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.SignalR;
namespace ColaFlow.API.Hubs;
/// <summary>
/// Project real-time collaboration Hub
/// </summary>
public class ProjectHub : BaseHub
{
/// <summary>
/// Join project room (to receive project-level updates)
/// </summary>
public async Task JoinProject(Guid projectId)
{
var tenantId = GetCurrentTenantId();
var userId = GetCurrentUserId();
// TODO: Validate user has permission to access this project
var groupName = GetProjectGroupName(projectId);
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
Console.WriteLine($"User {userId} joined project {projectId}");
// Notify other users that a new member joined
await Clients.OthersInGroup(groupName).SendAsync("UserJoinedProject", new
{
UserId = userId,
ProjectId = projectId,
JoinedAt = DateTime.UtcNow
});
}
/// <summary>
/// Leave project room
/// </summary>
public async Task LeaveProject(Guid projectId)
{
var userId = GetCurrentUserId();
var groupName = GetProjectGroupName(projectId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
// Notify other users that a member left
await Clients.OthersInGroup(groupName).SendAsync("UserLeftProject", new
{
UserId = userId,
ProjectId = projectId,
LeftAt = DateTime.UtcNow
});
}
/// <summary>
/// Send typing indicator (when editing an issue)
/// </summary>
public async Task SendTypingIndicator(Guid projectId, Guid issueId, bool isTyping)
{
var userId = GetCurrentUserId();
var groupName = GetProjectGroupName(projectId);
await Clients.OthersInGroup(groupName).SendAsync("TypingIndicator", new
{
UserId = userId,
IssueId = issueId,
IsTyping = isTyping
});
}
private string GetProjectGroupName(Guid projectId)
{
return $"project-{projectId}";
}
}

View File

@@ -1,6 +1,8 @@
using ColaFlow.API.Extensions;
using ColaFlow.API.Handlers;
using ColaFlow.API.Hubs;
using ColaFlow.API.Middleware;
using ColaFlow.API.Services;
using ColaFlow.Modules.Identity.Application;
using ColaFlow.Modules.Identity.Infrastructure;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -65,6 +67,25 @@ builder.Services.AddAuthentication(options =>
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured")))
};
// Configure SignalR to use JWT from query string (for WebSocket upgrade)
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
// If the request is for SignalR hub...
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
// Read the token from query string
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
// Configure Authorization Policies for RBAC
@@ -92,17 +113,35 @@ builder.Services.AddAuthorization(options =>
policy.RequireRole("AIAgent"));
});
// Configure CORS for frontend
// Configure CORS for frontend (SignalR requires AllowCredentials)
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
{
policy.WithOrigins("http://localhost:3000")
policy.WithOrigins("http://localhost:3000", "https://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod();
.AllowAnyMethod()
.AllowCredentials(); // Required for SignalR
});
});
// Configure SignalR
builder.Services.AddSignalR(options =>
{
// Enable detailed errors (development only)
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
// Client timeout settings
options.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
options.HandshakeTimeout = TimeSpan.FromSeconds(15);
// Keep alive interval
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
});
// Register Realtime Notification Service
builder.Services.AddScoped<IRealtimeNotificationService, RealtimeNotificationService>();
// Configure OpenAPI/Scalar
builder.Services.AddOpenApi();
@@ -138,6 +177,10 @@ app.UseAuthorization();
app.MapControllers();
// Map SignalR Hubs (after UseAuthorization)
app.MapHub<ProjectHub>("/hubs/project");
app.MapHub<NotificationHub>("/hubs/notification");
app.Run();
// Make the implicit Program class public for integration tests

View File

@@ -0,0 +1,15 @@
namespace ColaFlow.API.Services;
public interface IRealtimeNotificationService
{
// Project-level notifications
Task NotifyProjectUpdate(Guid tenantId, Guid projectId, object data);
Task NotifyIssueCreated(Guid tenantId, Guid projectId, object issue);
Task NotifyIssueUpdated(Guid tenantId, Guid projectId, object issue);
Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId);
Task NotifyIssueStatusChanged(Guid tenantId, Guid projectId, Guid issueId, string oldStatus, string newStatus);
// User-level notifications
Task NotifyUser(Guid userId, string message, string type = "info");
Task NotifyUsersInTenant(Guid tenantId, string message, string type = "info");
}

View File

@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.SignalR;
using ColaFlow.API.Hubs;
namespace ColaFlow.API.Services;
public class RealtimeNotificationService : IRealtimeNotificationService
{
private readonly IHubContext<ProjectHub> _projectHubContext;
private readonly IHubContext<NotificationHub> _notificationHubContext;
private readonly ILogger<RealtimeNotificationService> _logger;
public RealtimeNotificationService(
IHubContext<ProjectHub> projectHubContext,
IHubContext<NotificationHub> notificationHubContext,
ILogger<RealtimeNotificationService> logger)
{
_projectHubContext = projectHubContext;
_notificationHubContext = notificationHubContext;
_logger = logger;
}
public async Task NotifyProjectUpdate(Guid tenantId, Guid projectId, object data)
{
var groupName = $"project-{projectId}";
_logger.LogInformation("Sending project update to group {GroupName}", groupName);
await _projectHubContext.Clients.Group(groupName).SendAsync("ProjectUpdated", data);
}
public async Task NotifyIssueCreated(Guid tenantId, Guid projectId, object issue)
{
var groupName = $"project-{projectId}";
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueCreated", issue);
}
public async Task NotifyIssueUpdated(Guid tenantId, Guid projectId, object issue)
{
var groupName = $"project-{projectId}";
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueUpdated", issue);
}
public async Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId)
{
var groupName = $"project-{projectId}";
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueDeleted", new { IssueId = issueId });
}
public async Task NotifyIssueStatusChanged(
Guid tenantId,
Guid projectId,
Guid issueId,
string oldStatus,
string newStatus)
{
var groupName = $"project-{projectId}";
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueStatusChanged", new
{
IssueId = issueId,
OldStatus = oldStatus,
NewStatus = newStatus,
ChangedAt = DateTime.UtcNow
});
}
public async Task NotifyUser(Guid userId, string message, string type = "info")
{
var userConnectionId = $"user-{userId}";
await _notificationHubContext.Clients.User(userId.ToString()).SendAsync("Notification", new
{
Message = message,
Type = type,
Timestamp = DateTime.UtcNow
});
}
public async Task NotifyUsersInTenant(Guid tenantId, string message, string type = "info")
{
var groupName = $"tenant-{tenantId}";
await _notificationHubContext.Clients.Group(groupName).SendAsync("Notification", new
{
Message = message,
Type = type,
Timestamp = DateTime.UtcNow
});
}
}