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.
sequenceis connection-local transport ordering. It resets on reconnect.data.serverSequenceis the durable per-auction ordering/cursor value.data.stateVersionorders aggregate state mutations.timestampis the event occurrence time. The updated lifecycle payload does not addoccurredAt,changedAt, orresumedAtinsidedata.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.openedhasjoinWindow.status === 'OPEN'andclosedAt === null.auction.join_window.closedhasjoinWindow.status === 'CLOSED'and areason.- 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 observedjoinWindow.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:
- Public
auction.state.changedwithwinner_selectedorwinner_disqualified, complete lifecycle state, the public winner projection, and division counts. - Staff-only
auction.winner.changedwith 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:
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.isOnlineorpresenceStatussays whether the participant is currently present.- When one enrollment is both connected and floor-present,
ONLINEwins 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¶
- Keep the highest applied/reported durable cursor per auction.
- Reconnect and subscribe using that cursor.
- Replace aggregate and role-specific state from
auction.state.snapshot. - Sort visible
replay.eventsbyserverSequenceand deduplicate byid. - Feed lifecycle replay events through the same reducer as live events, but do not let an older
stateVersionregress the newer snapshot. - Apply staff prebid/winner/participation events only to staff stores.
- Set the cursor to
replay.lastSequence, not merely the last visible event. - 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¶
- Fetch subscriber auction detail and seed state.
- Subscribe with
auctionId, enrollment id, and optional durable cursor. - Replace state from the state and audience snapshots.
- Apply public lifecycle/join-window/bid events.
- Apply the participant (when present) included in
auction.audience.changedonly as an ephemeral projection; never merge its timestamp into durable REST history. - Place/edit/cancel a prebid over REST and resolve from the REST result.
- Place live bids with a stable command id; treat
acceptedandreplayedas success.
Staff console¶
- Fetch the company/admin auction overview and subscribe without an enrollment id.
- Seed participant rows using
source, neverbidderType. - Apply public lifecycle and join-window events.
- Apply staff
auction.prebid.created,auction.winner.changed, andauction.participation.joinedevents. - 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. - Keep async commands pending after
command.acknowledgeduntil a matching domain event, rejection/failure frame, or authoritative refetch resolves it.
11. Migration checklist¶
- Remove every
bidderTypeproperty and lower-case origin comparison. - Add
source: 'ONLINE' | 'FLOOR' | nullto staff participant models. - Decode
auction.state.changed.payload.stateas required and complete. - Stop reading root
payload.startedAt,pausedAt,resumedAt,auctionEndAt,closingPhase, orclosingPhaseEndsAt. - Add
approved,cancelled, andwinner_disqualifiedto transition unions. - Decode join-window data from
payload.joinWindowand itsstateVersion. - Add the staff-only
auction.winner.changedreducer/invalidation path. - Change
auction.prebid.createdto staff-only and durable. - Allow nullable prebid and prebid-sourced winner amounts.
- Decode the optional
participanton publicauction.audience.changed. - Keep
auction.audience.snapshotas 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
idand reject state-version regression.
12. Frontend acceptance tests¶
- Scheduling/rescheduling replaces all lifecycle and join-window fields.
- Approval moves a subscribed console to
READYfrom theapprovedevent. - Start, pause, resume, extension, and call-phase events replace server deadlines without client arithmetic.
- Cancellation and close immediately disable commands.
- Winner selection updates public division counters and staff winner detail.
- Winner disqualification handles
replacementRequiredand a nullable suggested replacement candidate. - A staff prebid-created event renders
amount: nullwithout crashing. - A subscriber prebid submission completes from REST without waiting for a WebSocket prebid-created event.
source: 'ONLINE'andsource: 'FLOOR'render correctly; no lower-case fallback is required.- Public audience changes decode the optional participant projection, while reconnect replaces counts from the audience snapshot.
- Replay with intentionally missing visible sequence numbers converges and advances to the reported high-water cursor.
- Replay/live duplicate event ids are applied once.
- An older lifecycle replay event cannot overwrite a newer snapshot
stateVersion.