using System; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using GB5Shared.DTO.Framework.Login; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace RecruitmentSL.Hubs { // Server-push-only hub: pipeline-stage changes on an Application (kanban board) and // interview-scheduling notifications to assigned interviewers. Driven entirely by // IHubContext pushes from ApplicationBLL/InterviewBLL via IRecruitmentHubNotifier — this // Hub itself defines no client-invoked RPCs (YAGNI, per CLAUDE.md's guidance for a // server-push-only module). public class RecruitmentHub : Hub { private readonly ILogger _logger; public RecruitmentHub(ILogger logger) { _logger = logger; } // ── Connection lifecycle ────────────────────────────────────────── public override async Task OnConnectedAsync() { var login = GetLoginDTO(); // Tenant-wide group — a kanban board watching all applications for the org. await Groups.AddToGroupAsync(Context.ConnectionId, ClientGroup(login.ClientId)); // Per-user group — an interviewer's personal notification stream. await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(login.UserId)); _logger.LogInformation( "RecruitmentHub connected: {ConnectionId} user {UserId} client {ClientId}", Context.ConnectionId, login.UserId, login.ClientId); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { var login = GetLoginDTO(); await Groups.RemoveFromGroupAsync(Context.ConnectionId, ClientGroup(login.ClientId)); await Groups.RemoveFromGroupAsync(Context.ConnectionId, UserGroup(login.UserId)); if (exception is not null) _logger.LogWarning(exception, "RecruitmentHub disconnected with error: {ConnectionId}", Context.ConnectionId); await base.OnDisconnectedAsync(exception); } // ── Group naming (CLAUDE.md convention) ───────────────────────────── // Per tenant/client: {module}:client:{clientId} // Per user: {module}:user:{userId} public static string ClientGroup(int clientId) => $"recruitment:client:{clientId}"; public static string UserGroup(int userId) => $"recruitment:user:{userId}"; // ── Helper ─────────────────────────────────────────────────────── // ALWAYS reconstruct LoginDTO from the HTTP context — never accept it from a client // payload. This module's real per-request identity convention is the serialized "Login" // header (see every FastEndpoint's `[FromHeader] string Login` under // RecruitmentSL/EndPoints) — there is no ClaimsPrincipal-based authentication wired up // for this host, so a claims-based lookup would silently resolve every field to its // default. Mirrors FLSSL.Hubs.FlsMonitorHub.GetLoginDTO exactly. private LoginDTO GetLoginDTO() { var httpContext = Context.GetHttpContext() ?? throw new InvalidOperationException("Hub invoked outside HTTP context."); var raw = httpContext.Request.Headers["Login"].FirstOrDefault() ?? throw new InvalidOperationException("Login header missing."); return JsonSerializer.Deserialize(raw) ?? throw new InvalidOperationException("Login header is invalid JSON."); } } }