Skip to content

Auction Current Changes — Complete Frontend Integration

This document is the migration guide for the current auction backend changes. It focuses on the contract changes that require frontend work: canonical lifecycle events, durable role-filtered replay, winner/prebid staff events, join-window payload nesting, and participant source normalization.

Use this guide for implementation and testing. The broader endpoint catalog remains in Auction frontend integration, and the event-by-event reference remains in Auction WebSocket events.

1. Change summary

Area Previous client assumption Current contract Required frontend change
Lifecycle event Transition-specific fields lived at payload.* Every durable auction.state.changed contains { transition, state, ...winnerFields } Replace lifecycle state from payload.state
Live versus replay Shapes differed Persisted live broadcast and replay entry use the same payload Use one reducer for both paths
Lifecycle coverage Only some transitions were durable Schedule, prebid, approval, start/pause/resume, extension/calls, close/cancel, and winner changes are durable Handle the complete transition union
Winner detail Inferred from lifecycle/refetch Staff receive durable auction.winner.changed Add a staff winner reducer or invalidate/refetch
Prebid creation Room-wide ephemeral event Durable staff-only event with a full nullable projection Subscribers must not wait for this event
Join-window event Window fields at payload root { stateVersion, joinWindow, reason? } Read all window fields from payload.joinWindow
Audience changed Public counts, staff participant Public and staff copies both contain counts plus an optional participant Update decoder and privacy expectations
Participant origin bidderType: 'online' | 'floor' source: 'ONLINE' | 'FLOOR' Rename field and preserve uppercase enum values
Replay cursor Visible sequence expected contiguous Replay is filtered and may be sparse; lastSequence is the global high-water mark Never infer loss from a numeric gap alone

There is no compatibility alias for bidderType, and updated lifecycle events do not duplicate timing fields at the payload root.

2. WebSocket envelope

The socket transport envelope and the durable auction cursor are separate:

interface AuctionSocketFrame<TType extends string, TPayload> {
  readonly id?: string;
  readonly type: TType;
  readonly version: '1.0';
  readonly source: string;
  readonly timestamp: string;
  readonly sequence: number;
  readonly correlationId?: string;
  readonly causationId?: string;
  readonly data: {
    readonly auctionId: string;
    readonly cycleId: string;
    readonly stateVersion?: number;
    readonly serverSequence?: number;
    readonly requestId?: string;
    readonly bidId?: string;
    readonly leadingBidAmount?: number;
    readonly payload: TPayload;
    readonly error?: unknown;
  };
}

data.error is present only on failure frames; normal domain events omit it.

  • sequence is connection-local transport ordering. It resets on reconnect.
  • data.serverSequence is the durable per-auction ordering/cursor value.
  • data.stateVersion orders aggregate state mutations.
  • timestamp is the event occurrence time. The updated lifecycle payload does not add occurredAt, changedAt, or resumedAt inside data.payload.
  • Persisted events have a durable id; deduplicate replay/live overlap by it.

3. Canonical lifecycle contract

type AuctionStatus =
  | 'PENDING'
  | 'SCHEDULED'
  | 'READY'
  | 'LIVE'
  | 'PAUSED'
  | 'ENDED'
  | 'COMPLETE'
  | 'CANCELLED';

type AuctionCurrentPhase =
  | 'PENDING'
  | 'PREBID_OPEN'
  | 'PREBID_CLOSED'
  | 'PENDING_APPROVAL'
  | 'READY_TO_START'
  | 'LIVE_BIDDING'
  | 'PAUSED'
  | 'ENDED'
  | 'COMPLETE'
  | 'CANCELLED';

type AuctionTransition =
  | 'schedule_changed'
  | 'prebid_opened'
  | 'prebid_closed'
  | 'prebids_revealed'
  | 'approved'
  | 'started'
  | 'paused'
  | 'resumed'
  | 'extended'
  | 'call_started'
  | 'closed'
  | 'cancelled'
  | 'winner_selected'
  | 'winner_disqualified';

interface AuctionJoinWindow {
  readonly status: 'SCHEDULED' | 'OPEN' | 'CLOSED';
  readonly version: number;
  readonly joiningRule: 'BEFORE_START' | 'GRACE_PERIOD' | 'ANYTIME';
  readonly opensAt: string | null;
  readonly openedAt: string | null;
  readonly closesAt: string | null;
  readonly closedAt: string | null;
}

interface AuctionLifecycleState {
  readonly status: AuctionStatus;
  readonly stateVersion: number;
  readonly currentPhase: AuctionCurrentPhase;
  readonly prebidPhase: 'NONE' | 'OPEN' | 'CLOSED';
  readonly auctionStartAt: string | null;
  readonly prebidStartAt: string | null;
  readonly prebidEndAt: string | null;
  readonly auctionEndAt: string | null;
  readonly startedAt: string | null;
  readonly pausedAt: string | null;
  readonly closingPhase: 'FIRST_CALL' | 'SECOND_CALL' | 'THIRD_CALL' | null;
  readonly closingPhaseEndsAt: string | null;
  readonly prebidAmountsRevealed: boolean;
  readonly prebidAmountsRevealedAt: string | null;
  readonly approvalRequired: boolean;
  readonly cycleStatus: 'UPCOMING' | 'ACTIVE' | 'ENDED';
  readonly joinWindow: AuctionJoinWindow;
}

type WinnerSelectionSource =
  'PREBID_REVIEW' | 'LIVE_BID_REVIEW' | 'MANUAL_SELECTION' | 'LOT_SELECTION';

interface PublicDeclaredWinner {
  readonly id: string;
  readonly subscriberId: string;
  readonly enrolledSubscriberId: string;
  readonly subscriberName: string | null;
  readonly subscriberAvatar: string | null;
  readonly amount: number | null;
  readonly division: number | null;
  readonly status: 'DECLARED' | 'DISQUALIFIED';
  readonly type: 'AUCTION' | 'LOT' | null;
  readonly selectionSource: WinnerSelectionSource | null;
  readonly auctionBidId: string | null;
  readonly auctionPrebidId: string | null;
  readonly selectedAt: string | null;
}

interface AuctionLifecyclePayload {
  readonly transition: AuctionTransition;
  readonly state: AuctionLifecycleState;
  readonly winner?: PublicDeclaredWinner;
  readonly declaredWinnerCount?: number;
  readonly remainingWinnerSlots?: number;
  readonly replacementRequired?: boolean;
}

Reducer rule

Treat payload.state as replacement state, not a patch. transition is for animations, notifications, analytics, and targeted invalidation.

interface AuctionClientState {
  readonly lifecycle: AuctionLifecycleState;
  readonly durableCursor: number;
  readonly processedEventIds: ReadonlySet<string>;
}

function applyLifecycleEvent(
  current: AuctionClientState,
  frame: AuctionSocketFrame<'auction.state.changed', AuctionLifecyclePayload>
): AuctionClientState {
  const { id, data } = frame;
  if (id && current.processedEventIds.has(id)) return current;
  if (data.payload.state.stateVersion < current.lifecycle.stateVersion) return current;

  return {
    lifecycle: data.payload.state,
    durableCursor: Math.max(current.durableCursor, data.serverSequence ?? 0),
    processedEventIds: id
      ? new Set([...current.processedEventIds, id])
      : current.processedEventIds
  };
}

Do not calculate pause duration, extension duration, join-window state, or current phase in this reducer. Those values are already resolved by the server.

Transition handling

Transition UI effect beyond replacing state
schedule_changed Refresh schedule/settings-dependent forms and countdowns
prebid_opened Enable prebid UI only if current-user gates also allow it
prebid_closed Lock prebid UI and expose the appropriate start/approval path
prebids_revealed Invalidate auction detail/winner/prebid projections
approved Resolve pending approve action and render READY controls
started Enter live-room presentation; use state.startedAt
paused Freeze countdown at server state; disable live bidding
resumed Resume countdown against the shifted server deadline
extended Replace the deadline; optionally show an extension notice
call_started Render the new call phase and closingPhaseEndsAt
closed Stop bidding and enter winner-review state
cancelled Stop all auction actions and render cancellation state
winner_selected Apply optional public winner and division counters
winner_disqualified Remove/mark winner and honor replacementRequired

4. Join-window events

Both join-window events are public, durable, replayable, and ordered by serverSequence.

interface AuctionJoinWindowEventPayload {
  readonly stateVersion: number;
  readonly joinWindow: AuctionJoinWindow;
  readonly reason?:
    'STARTED' | 'GRACE_EXPIRED' | 'AUCTION_ENDED' | 'CANCELLED' | 'RESCHEDULED';
}
  • auction.join_window.opened has joinWindow.status === 'OPEN' and closedAt === null.
  • auction.join_window.closed has joinWindow.status === 'CLOSED' and a reason.
  • Rescheduling may persist a close for the old version, an open for a new version, and schedule_changed. Apply sequence order, then prefer the highest observed joinWindow.version.
  • The lifecycle event also embeds the authoritative post-transition state.joinWindow; it is safe to replace the window from that value.

5. Winner events

Winner mutations now produce two complementary durable events:

  1. Public auction.state.changed with winner_selected or winner_disqualified, complete lifecycle state, the public winner projection, and division counts.
  2. Staff-only auction.winner.changed with detailed console/audit data.
interface StaffDeclaredWinner extends PublicDeclaredWinner {
  readonly subscriberEmail: string | null;
  readonly subscriberMobileNumber: string | null;
  readonly selectedById: string | null;
  readonly replacedById: string | null;
  readonly replacedAt: string | null;
  readonly disqualifiedById: string | null;
  readonly disqualifiedAt: string | null;
  readonly disqualificationReasonCode: string | null;
  readonly disqualificationNote: string | null;
}

interface StaffWinnerReplacementCandidate {
  readonly source: 'PREBID' | 'LIVE';
  readonly sourceId: string;
  readonly subscriberId: string;
  readonly enrolledSubscriberId: string;
  readonly subscriberName: string | null;
  readonly subscriberAvatar: string | null;
  readonly subscriberEmail: string | null;
  readonly subscriberMobileNumber: string | null;
  readonly amount: number;
  readonly createdAt: string;
}

interface AuctionWinnerChangedPayload {
  readonly action: 'SELECTED' | 'DISQUALIFIED';
  readonly winner: StaffDeclaredWinner;
  readonly suggestedReplacementCandidate?: StaffWinnerReplacementCandidate | null;
}

Prebid-sourced winner amounts remain null in the public projection until prebid amounts are revealed. Staff projections follow the backend's staff visibility rule. Never reveal a value by combining a public winner with a locally cached sealed prebid.

6. Prebid creation event

auction.prebid.created is now staff-only, durable, and replayable:

interface AuctionPrebidCreatedPayload {
  readonly id: string;
  readonly subscriberId: string;
  readonly enrolledSubscriberId: string;
  readonly bidderName: string;
  readonly amount: number | null;
  readonly createdAt: string;
  readonly status: string;
  readonly documentSubmissionId: string | null;
}

Staff consoles can insert/invalidate a prebid row from this event. Treat amount as nullable. Subscriber clients must update their own prebid from the successful REST response and subsequent snapshot/detail data; waiting for this staff event leaves the subscriber UI pending forever.

7. Participation and presence

Field rename

Replace every participant/audience use of:

interface RemovedParticipantOrigin {
  readonly bidderType: 'online' | 'floor' | null;
}

with:

type AuctionParticipationSource = 'ONLINE' | 'FLOOR';

interface ParticipantOrigin {
  readonly source: AuctionParticipationSource | null;
}

This affects staff auction overview participants, staff state snapshots, onlineSubscribers, and auction.audience.changed participant projections. The subscriber roster continues to call durable admission origin joinSource, but it uses the same uppercase enum.

source and presence are different concepts:

  • source: 'ONLINE' | 'FLOOR' says where the current participation/presence representation came from.
  • isOnline or presenceStatus says whether the participant is currently present.
  • When one enrollment is both connected and floor-present, ONLINE wins in the current snapshot.

Audience changed payload

interface AuctionAudienceParticipant {
  readonly subscriberId: string;
  readonly enrolledSubscriberId: string;
  readonly name: string | null;
  readonly avatar: string | null;
  readonly accessMode: 'VIEWER' | 'PARTICIPANT';
  readonly lastSeenAt: string;
  readonly connectionCount: number;
  readonly source: AuctionParticipationSource;
}

interface AuctionAudienceChangedPayload {
  readonly participant?: AuctionAudienceParticipant;
  readonly viewerCount: number;
  readonly joinedParticipantCount: number;
  readonly onlineParticipantCount: number;
  readonly bidderCount: number;
}

participant is optional: join/leave and floor-presence transitions include it, but some transitions (for example a presence upgrade that cannot resolve the participant row) publish counts only, so never require it when decoding.

The backend currently publishes this complete payload to both public and staff channels. auction.audience.snapshot remains counts-only for subscribers. Audience events are ephemeral and have no durable id or serverSequence; replace them from the next snapshot after reconnect.

8. Event visibility and durability

Event Subscriber Staff Durable/replayable
auction.state.changed Yes Yes Yes
auction.join_window.opened / .closed Yes Yes Yes
auction.bid.created / .updated Public/target rules Yes Yes
auction.participation.joined No Yes Yes
auction.prebid.created No Yes Yes
auction.winner.changed No Yes Yes
auction.audience.changed Yes Yes No
auction.audience.snapshot Counts only Counts plus staff state in the state snapshot No

9. Snapshot and replay

Subscription order is:

ack(auction.subscribe)
auction.connected
auction.state.snapshot
auction.audience.snapshot
live events

Reconnect with the last applied high-water cursor:

{
  "id": "subscribe-002",
  "type": "auction.subscribe",
  "data": {
    "auctionId": "auction-id",
    "enrolledSubscriberId": "enrollment-id",
    "lastSequence": 42
  }
}

Staff omit enrolledSubscriberId. When supplied, replay appears inside the state snapshot:

interface AuctionReplay {
  readonly auction: {
    readonly auctionId: string;
    readonly cycleId: string;
    readonly cycleNumber: number;
    readonly programId: string;
    readonly programName: string;
    readonly companyId: string;
    readonly companyName: string | null;
    readonly auctionStatus: AuctionStatus;
    readonly leadingBidId: string | null;
    readonly leadingBidAmount: string | null;
    readonly hasPrebid?: boolean;
    readonly prebidAmountsRevealedAt?: string | null;
    readonly auctionStartAt: string | null;
    readonly auctionClosingMode: 'DURATION_MODE' | 'CALL_MODE';
    readonly auctionDurationSeconds: number | null;
    readonly startedAt: string | null;
    readonly accumulatedPausedMs?: number | string | null;
    readonly accumulatedExtensionMs?: number | string | null;
    readonly endedAt: string | null;
    readonly closingPhase: 'FIRST_CALL' | 'SECOND_CALL' | 'THIRD_CALL' | null;
    readonly closingPhaseEndsAt: string | null;
  };
  readonly events: ReadonlyArray<{
    readonly type: string;
    readonly id: string;
    readonly serverSequence: number;
    readonly occurredAt: string;
    readonly payload: Readonly<Record<string, unknown>>;
    readonly scope?: 'public' | 'staff';
    readonly targetUserId?: string | null;
  }>;
  readonly lastSequence: number;
}

Timestamps arrive as ISO strings; accumulated pause/extension durations may arrive as numbers or strings depending on serialization, so parse them leniently.

Correct recovery algorithm

  1. Keep the highest applied/reported durable cursor per auction.
  2. Reconnect and subscribe using that cursor.
  3. Replace aggregate and role-specific state from auction.state.snapshot.
  4. Sort visible replay.events by serverSequence and deduplicate by id.
  5. Feed lifecycle replay events through the same reducer as live events, but do not let an older stateVersion regress the newer snapshot.
  6. Apply staff prebid/winner/participation events only to staff stores.
  7. Set the cursor to replay.lastSequence, not merely the last visible event.
  8. Replace ephemeral counters from auction.audience.snapshot.

The durable sequence is allocated before role filtering. For example, a subscriber may see sequences 40 and 44 while 41–43 are staff-only. That is not a replay failure. Recover because of actual socket loss, malformed data, or state inconsistency—not because next !== previous + 1.

A fresh subscription to an auction that already started may include the stored started lifecycle event in replay.events, even when no lastSequence was sent. Treat reapplying it as idempotent and keep the snapshot as the authoritative current state.

10. Role-specific implementation flows

Subscriber

  1. Fetch subscriber auction detail and seed state.
  2. Subscribe with auctionId, enrollment id, and optional durable cursor.
  3. Replace state from the state and audience snapshots.
  4. Apply public lifecycle/join-window/bid events.
  5. Apply the participant (when present) included in auction.audience.changed only as an ephemeral projection; never merge its timestamp into durable REST history.
  6. Place/edit/cancel a prebid over REST and resolve from the REST result.
  7. Place live bids with a stable command id; treat accepted and replayed as success.

Staff console

  1. Fetch the company/admin auction overview and subscribe without an enrollment id.
  2. Seed participant rows using source, never bidderType.
  3. Apply public lifecycle and join-window events.
  4. Apply staff auction.prebid.created, auction.winner.changed, and auction.participation.joined events.
  5. Expect the public and staff copies of auction.audience.changed; since these events have no durable id, make presence updates idempotent by participant identity and replace the four counters from each payload.
  6. Keep async commands pending after command.acknowledged until a matching domain event, rejection/failure frame, or authoritative refetch resolves it.

11. Migration checklist

  • Remove every bidderType property and lower-case origin comparison.
  • Add source: 'ONLINE' | 'FLOOR' | null to staff participant models.
  • Decode auction.state.changed.payload.state as required and complete.
  • Stop reading root payload.startedAt, pausedAt, resumedAt, auctionEndAt, closingPhase, or closingPhaseEndsAt.
  • Add approved, cancelled, and winner_disqualified to transition unions.
  • Decode join-window data from payload.joinWindow and its stateVersion.
  • Add the staff-only auction.winner.changed reducer/invalidation path.
  • Change auction.prebid.created to staff-only and durable.
  • Allow nullable prebid and prebid-sourced winner amounts.
  • Decode the optional participant on public auction.audience.changed.
  • Keep auction.audience.snapshot as the reconnect source for ephemeral counts.
  • Treat durable sequences as sparse and advance to replay.lastSequence.
  • Use one lifecycle reducer for live and replay events.
  • Deduplicate durable events by id and reject state-version regression.

12. Frontend acceptance tests

  1. Scheduling/rescheduling replaces all lifecycle and join-window fields.
  2. Approval moves a subscribed console to READY from the approved event.
  3. Start, pause, resume, extension, and call-phase events replace server deadlines without client arithmetic.
  4. Cancellation and close immediately disable commands.
  5. Winner selection updates public division counters and staff winner detail.
  6. Winner disqualification handles replacementRequired and a nullable suggested replacement candidate.
  7. A staff prebid-created event renders amount: null without crashing.
  8. A subscriber prebid submission completes from REST without waiting for a WebSocket prebid-created event.
  9. source: 'ONLINE' and source: 'FLOOR' render correctly; no lower-case fallback is required.
  10. Public audience changes decode the optional participant projection, while reconnect replaces counts from the audience snapshot.
  11. Replay with intentionally missing visible sequence numbers converges and advances to the reported high-water cursor.
  12. Replay/live duplicate event ids are applied once.
  13. An older lifecycle replay event cannot overwrite a newer snapshot stateVersion.