Skip to content

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 autoDeclareWinner policy 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

GET /v2/{admin|companies}/auctions/{auctionId}/eligible-winner-candidates?pageNumber=1&pageSize=20

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

POST /v2/{admin|companies}/auctions/{auctionId}/winner/disqualify

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):

POST /v2/companies/cycles/{cycleId}/winners

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:

{
  "type": "auction.winner.declare",
  "id": "declare-1",
  "data": { ... }
}

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.

  1. The end transition commits ENDED and an immediate next timestamp when an automatic post-end action is required.
  2. A separate zero-delay job reveals prebids when configured, then persists and enqueues the next immediate transition.
  3. 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 ENDED and cycle status is ACTIVE.
  • nextTransitionAt is 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