Declaring Auction Winners¶
Winners for an auction cycle can be declared manually by staff or automatically by the database-driven lifecycle processor. Both paths share one implementation (makeDeclareWinnerV2Command) and publish the same realtime event, so staff and automated decisions are indistinguishable downstream except for the recorded action type (DECLARE_WINNER vs AUTO_DECLARE_WINNER).
flowchart LR
subgraph Manual
WS["WebSocket /ws<br/>auction.winner.declare / auction.winner.record_lot"]
REST["REST /v2/(admin|companies)/auctions/:auctionId<br/>winner endpoints"]
end
subgraph Auto
JOB["per-auction delayed job"]
RECONCILE["30-second reconciler"]
DUE[("next_transition_at")]
END["end transition"]
REVEAL["reveal transition"]
DECLARE["declare transition"]
end
WS --> CMD
REST --> CMD
JOB --> DUE
RECONCILE --> DUE
DUE --> END
END --> REVEAL
REVEAL --> DECLARE
END --> DECLARE
CMD["declareWinnerV2Command"] --> DB[("cycle_winners")]
CMD --> EVT["auction.state.changed<br/>transition: winner_selected"] - Manual — staff actions via WebSocket (
/ws) and HTTP REST (admin/company auction console). - Automatic — a delayed job advances a due PostgreSQL transition when the frozen
autoDeclareWinnerpolicy is enabled; the reconciler repairs missed jobs.
The manual and automatic paths differ only in the actor (actorId = staff user vs null) and the trigger ('STAFF' vs 'AUTO'), which is recorded in the auction action log (src/application/use-cases/auctions/commands/declare-winner/declare-winner-command.ts:225).
1. REST API (manual, admin/company console)¶
All endpoints below are registered twice — once for SUPERADMIN and once for COMPANY — by registerStaffAuctionRoutes (src/interfaces/http/routes/common/auctions/staff-auction-routes.ts:38).
| Role | Base path |
|---|---|
| Superadmin | /v2/admin/auctions/{auctionId} |
| Company | /v2/companies/auctions/{auctionId} |
Authorization: authenticate + authorize([role]). Company requests are scoped to auctions owned by the calling company.
List eligible winner candidates¶
Returns the paginated list of candidates eligible to be declared the next winner for the current open division (used to populate the staff review screen).
| Query param | Type | Required | Description |
|---|---|---|---|
pageNumber | integer | No | Page number. |
pageSize | integer | No | Page size. |
Replace auction winner¶
There is no PATCH /winner endpoint. To replace a declared winner, disqualify the current one (POST .../winner/disqualify below) and then declare the next candidate via the WebSocket auction.winner.declare command (mode CANDIDATE with a different bidId / prebidId).
Disqualify auction winner¶
Disqualifies the current winner (disqualifyWinner). After disqualification the next lowest candidate becomes eligible and can be declared.
| Body field | Type | Required | Description |
|---|---|---|---|
winnerId | string | Yes | Winner record to disqualify. |
disqualificationReasonCode | string | Yes | KYC_ISSUE, PAYMENT_ISSUE, ELIGIBILITY_ISSUE, RULE_VIOLATION, OTHER. |
disqualificationNote | string | No | Staff note on the disqualification. |
reason | string | No | Free-text reason recorded in the action log. |
Bulk cycle winners (program setup)¶
Program-setup flow (not the auction review screen):
Inserts winners in bulk for a cycle. See src/interfaces/http/routes/v2/companies/cycles/[cycleId]/winners/cycle-winners-routes.ts:26.
2. WebSocket (manual, real-time staff review)¶
Endpoint: GET /ws (src/interfaces/websocket/index.ts:25), WebSocket upgrade. Auth is negotiated via the sec-websocket-protocol header (e.g. websocket.v1, bearer.company-token). All commands below require the auction:staff permission and an auctionId.
Staff commands are async: the server accepts the command and returns an immediate command.acknowledged; business results arrive via broadcast domain events (see Outgoing events).
Message envelope:
auction.winner.declare¶
Schema: auctionWinnerDeclareSchema in src/interfaces/websocket/handlers/auction/schemas.ts. data requires auctionId and mode: 'CANDIDATE' | 'MANUAL'.
Mode CANDIDATE — declare the next winner from the current lowest live bid or prebid (buildWinnerDeclareCommand in command-builders.ts):
{
"type": "auction.winner.declare",
"id": "declare-1",
"data": {
"auctionId": "auction-1",
"mode": "CANDIDATE",
"bidId": "bid-1",
"prebidId": "prebid-1",
"reason": "Lowest live bid"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
auctionId | string | Yes | The auction id. |
mode | string | Yes | CANDIDATE. |
bidId | string | No | Live-bid source. |
prebidId | string | No | Prebid source. Exactly one of bidId/prebidId. |
reason | string | No | Free-text reason. |
Mode MANUAL — declare a specific subscriber who did not bid (staff override → declareManualWinner):
{
"type": "auction.winner.declare",
"id": "declare-1",
"data": {
"auctionId": "auction-1",
"mode": "MANUAL",
"enrolledSubscriberId": "enrolled-1",
"amount": "250",
"reason": "Final decision"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
auctionId | string | Yes | The auction id. |
mode | string | Yes | MANUAL. |
enrolledSubscriberId | string | Yes | Enrollment of the subscriber to declare. |
amount | number|string | Yes | Winning amount (number or decimal string). |
reason | string | Yes | Free-text reason. |
auction.winner.record_lot¶
Declares a winner when a tie must be decided by lot. The auctionWinnerRecordLotSchema requires at least two candidateEnrolledSubscriberIds.
{
"type": "auction.winner.record_lot",
"id": "lot-1",
"data": {
"auctionId": "auction-1",
"winnerEnrolledSubscriberId": "enrolled-1",
"candidateEnrolledSubscriberIds": ["enrolled-2", "enrolled-3"],
"reason": "Lot recorded"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
auctionId | string | Yes | The auction id. |
winnerEnrolledSubscriberId | string | Yes | Chosen winner enrollment. |
candidateEnrolledSubscriberIds | string[] | Yes | All tied candidates (min 2). |
reason | string | No | Free-text reason. |
auction.bid.mark (moderation)¶
auction.bid.mark supports two moderation actions:
| action | Required fields |
|---|---|
DELETE_BID | bidId + reason (reasonCode optional) |
DISQUALIFY_BIDDER | enrolledSubscriberId + reason + reasonCode |
reasonCode ∈ KYC_ISSUE | PAYMENT_ISSUE | ELIGIBILITY_ISSUE | RULE_VIOLATION | OTHER.
Acknowledgment¶
Async staff commands are acknowledged immediately:
{
"type": "command.acknowledged",
"correlationId": "declare-1",
"data": { "command": "auction.winner.declare", "status": "accepted" }
}
Schema/authorization failures are returned synchronously (command.rejected / error), business rejections as command.failed.
3. Automatic declaration (database lifecycle)¶
Triggered when the frozen config snapshot enables autoDeclareWinner. PostgreSQL stores the next due step in auctions.next_transition_at; the single per-auction BullMQ job normally wakes the database-driven processor at that timestamp. The 30-second reconciler repairs missing jobs once they are due.
- The end transition commits
ENDEDand an immediate next timestamp when an automatic post-end action is required. - A separate zero-delay job reveals prebids when configured, then persists and enqueues the next immediate transition.
- Another zero-delay job declares an unambiguous winner when configured. If live-bid candidates and undeclared divisions remain, it enqueues another immediate declaration job.
Only one transition is applied per job, so reveal and declaration remain separately observable without waiting for the reconciler interval.
Guard conditions¶
The worker only fires when all hold (process-declare-winner-command.ts:67):
- Auction status is
ENDEDand cycle status isACTIVE. nextTransitionAtis still due after locking the auction row.- The frozen config snapshot still enables
autoDeclareWinner. - Sealed prebid amounts are revealed (when the program has prebid).
- Winner selection is allowed only when
auctionStatus === 'ENDED'. - There is exactly one eligible lowest live bid — or, with no live bids, exactly one lowest prebid.
Ties, empty candidate sets, prebid-fallback and lot/replacement flows are deliberately left to staff (process-declare-winner-command.ts:128). When the auction ended with no live bids, staff may declare from the sealed prebid pool (prebid fallback — auction.winner.declare { prebidId }) or record a lot at the lowest amount (auction.winner.record_lot). Declaring a prebid-fallback winner on an auction that still has remaining divisions reopens it to SCHEDULED. The worker swallows BadRequestError/NotFoundError (a concurrent staff decision or shifting candidate set) as a no-op. The persisted timestamp is cleared when no automatic declaration can be made, leaving manual review explicit.
There is no reconciliation lease or delayed per-auction job. Overdue database timestamps remain discoverable after worker restart, and overlapping workers serialize through the auction row lock.
4. Outgoing events¶
Both paths publish auction.state.changed with payload.transition: "winner_selected" and the complete post-transition payload.state. Staff subscribers also receive a durable auction.winner.changed event with action: "SELECTED" and the detailed winner projection. Winner disqualification emits the corresponding public winner_disqualified lifecycle transition and staff auction.winner.changed { action: "DISQUALIFIED" } event, including an optional suggested replacement candidate. The example abbreviates the complete lifecycle state to winner-relevant fields:
{
"type": "auction.state.changed",
"data": {
"auctionId": "auc_01HQ5BWNJ5Y1P5RX0W5X0W5X0W5X",
"cycleId": "cyc_01HQ5BWNJ5Y1P5RX0W5X0W5X0W5X",
"stateVersion": 11,
"serverSequence": 51,
"payload": {
"transition": "winner_selected",
"state": {
"status": "COMPLETE",
"stateVersion": 11,
"currentPhase": "COMPLETE"
},
"winner": {
"id": "winner-id",
"subscriberId": "subscriber-id",
"enrolledSubscriberId": "enrollment-id",
"amount": 25000,
"division": 1,
"status": "DECLARED",
"selectionSource": "LIVE_BID_REVIEW",
"auctionBidId": "bid_01HQ5BY3K7Z2V6Y0W5X0W5X0W5X",
"auctionPrebidId": null
},
"declaredWinnerCount": 1,
"remainingWinnerSlots": 0
}
}
}
The event is fanned out to the auction:{auctionId} and auction:staff:{auctionId} rooms. The acknowledgment envelope never carries business data; clients must apply state from these events.
5. Key files¶
| Concern | File |
|---|---|
| WS command schemas & builders | src/interfaces/websocket/handlers/auction/schemas.ts, command-builders.ts |
| WS mount | src/interfaces/websocket/index.ts |
| WS → use-case dispatch | src/application/use-cases/auction-rooms/auction-room-service.ts:258 |
| HTTP staff/admin routes | src/interfaces/http/routes/common/auctions/staff-auction-routes.ts |
| Shared declare implementation | src/application/use-cases/auctions/commands/declare-winner/declare-winner-command.ts |
| Auto-declare worker guard | src/application/use-cases/auctions/commands/process-declare-winner/process-declare-winner-command.ts |
| Lifecycle wake-up | src/application/queue/services/auction-queue.service.ts |
| Lifecycle processor | src/application/queue/processors/auction.processor.ts |
| Due-auction sweep | src/application/services/auction-lifecycle/auction-lifecycle.service.ts |