DEV Community

Cover image for 3 Bugs That Make a Real-Time CRM Board Lie About the Forecast
MICHAEL-MAURICE
MICHAEL-MAURICE

Posted on

3 Bugs That Make a Real-Time CRM Board Lie About the Forecast

Two sales reps, one deal. Sara moves "POS rollout" to Proposal. Omar still has yesterday's page open and marks it Lost a minute later. Who wins?

In most boards I've shipped, the answer was "whoever clicked last," and finance found out when the forecast was already wrong.

Pushing live updates is the easy 20%. The other 80% is stale writes, board isolation, and what happens when the browser reconnects. This post is the short "three bugs" cut for Blazor interactive server + SignalR on .NET 10 — skeleton shapes only. The full board (hub, REST, tests with two fake reps) lives in CRM PRO. Daily tested .NET projects are also in Tech Skill Builder — $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.

Bug 1: last-write-wins with no version

If your move endpoint only takes dealId + newStage, Omar's Lost overwrites Sara's Proposal with no warning. Put a version on the deal and reject mismatches:

public sealed record MoveDealRequest(Guid DealId, PipelineStage To, int ExpectedVersion);

public enum MoveStatus { Moved, NotFound, Conflict, InvalidTransition }

public sealed record MoveDealResult(MoveStatus Status, DealDto? Deal);
Enter fullscreen mode Exit fullscreen mode

Inside one lock (or a DB concurrency token), compare versions before applying the rule:

if (deal.Version != request.ExpectedVersion)
    return (new(MoveStatus.Conflict, deal.ToDto()), from);

if (!StageRules.CanMove(from, request.To))
    return (new(MoveStatus.InvalidTransition, deal.ToDto()), from);

deal.MoveTo(request.To);
// full implementation in the complete project
Enter fullscreen mode Exit fullscreen mode

On conflict, return the current deal. The losing rep sees reality, not a blank 409.

In production that token is SQL Server rowversion or Postgres xmin — same result shape, catch DbUpdateConcurrencyException.

Bug 2: every board hears every move

Magic-string SendAsync("DealMoved", …) to Clients.All is how "acme" leaks into "globex." Use typed hubs and groups:

public interface IPipelineClient
{
    Task DealMoved(DealMovedEvent e);
}

public sealed class PipelineHub(PipelineService pipeline) : Hub<IPipelineClient>
{
    public static string GroupFor(string boardId) => $"board:{boardId}";

    public async Task<IReadOnlyList<DealDto>> JoinBoard(string boardId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, GroupFor(boardId));
        return pipeline.GetBoard(boardId); // snapshot on join
    }

    public Task<MoveDealResult> MoveDeal(MoveDealRequest request) =>
        pipeline.MoveAsync(request, Context.UserIdentifier ?? Context.ConnectionId);
}
Enter fullscreen mode Exit fullscreen mode

Broadcast only on success, and only to that board's group — from a shared service so REST webhooks update open boards the same way:

if (result.Status != MoveStatus.Moved) return result; // nothing broadcast on failure

await hub.Clients.Group(PipelineHub.GroupFor(result.Deal!.BoardId))
    .DealMoved(new DealMovedEvent(result.Deal, from, movedBy));
Enter fullscreen mode Exit fullscreen mode

Bug 3: reconnect keeps a zombie board

WithAutomaticReconnect() restores the socket. It does not re-join your group or refresh the snapshot unless you hook it.

_hub = new HubConnectionBuilder()
    .WithUrl(Nav.ToAbsoluteUri("/hubs/pipeline"))
    .WithAutomaticReconnect()
    .Build();

_hub.Reconnected += async _ =>
{
    _deals = (await _hub.InvokeAsync<IReadOnlyList<DealDto>>("JoinBoard", _board)).ToList();
    await InvokeAsync(StateHasChanged);
};

// full implementation in the complete project — also: never open the hub during prerender
Enter fullscreen mode Exit fullscreen mode

Open the connection in OnAfterRenderAsync(firstRender) only. Prerender + hub = double connect and mysterious empty boards.

Stage rules belong in one place the UI, hub, and REST all call:

public static bool CanMove(PipelineStage from, PipelineStage to) => (from, to) switch
{
    (PipelineStage.Won or PipelineStage.Lost, _) => false,
    (_, PipelineStage.Lost) => true,
    _ => to == from + 1
};
Enter fullscreen mode Exit fullscreen mode

Won/Lost stay terminal. Reopening is a separate audited action, not a stray drag.

Prove it with two fake reps

Don't stop at "the hub starts." Spin two SignalR clients in an integration test, join the same board, race a move with a stale version, and assert one Conflict plus one live DealMoved on the other connection. That test is the whole point of a real-time CRM tutorial.

Want the complete CRM?

CRM PRO is a full .NET 10 + Blazor Clean Architecture CRM with real-time pipeline pieces built in — not just the tutorial board.

For a new tested .NET + AI project every day (full source + PDF), Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.

Have you shipped a SignalR board that silently served stale state after reconnect? Tell me how you caught it.

Top comments (0)