Skip to main content

tournaments-api

Tournaments API

  • Base Path: /api/v1/tournaments
  • Auth: required

Tournament Model

  • _id: string (ObjectId)
  • game: string (ObjectId)
  • product: string (ObjectId)
  • seller: string (User ID)
  • leaderboard: string | null (ObjectId)
  • status: 'OPEN' | 'IN_PROGRESS' | 'OVER' | 'UNFILLED'
  • entryFee: number (points required to join; default 0)
  • rules: string (plain text rules for the tournament)
  • startAt: string (ISO) | null
  • endedAt: string (ISO) | null
  • winner: string | null (User ID)
  • totalSeats: number
  • expectedPoints: number
  • collectedPoints: number
  • numberOfPlayers: number
  • expectedPlayers: number
  • cancellationReason: string (optional; set when seller cancels)
  • earlyTermination: object (optional)
    • enabled: boolean (whether early termination is allowed)
    • thresholdPct: number (progress percent required to allow early termination; default 80)
  • createdAt / updatedAt: string (ISO)

Example tournament document:

{
"_id": "66f000000000000000001001",
"game": "66f000000000000000000101",
"product": "66f000000000000000000001",
"seller": "john_doe",
"status": "IN_PROGRESS",
"entryFee": 10,
"rules": "Finish in time. No exploits.",
"totalSeats": 10,
"numberOfPlayers": 4,
"expectedPoints": 100,
"collectedPoints": 40,
"startAt": "2025-09-01T10:00:00.000Z",
"endedAt": null,
"createdAt": "2025-09-01T10:00:00.000Z",
"updatedAt": "2025-09-01T10:00:00.000Z"
}

Get Active Tournament By Product

  • Method: GET
  • Path: /api/v1/tournaments?product=:productId
  • Auth: required
  • Query:
    • product: string (required)
  • Response 200: Tournament document (no populate)
  • Errors:
    • 400: { "error": "product is required" }
    • 404: { "error": "Tournament not found" }
    • 500: { "error": "Failed to fetch tournament" }

Join Tournament

  • Method: POST
  • Path: /api/v1/tournaments/:id/join
  • Auth: required (no guests)
  • Path Params:
    • id: string (tournament ObjectId)
  • Request Body: none
  • Behavior:
    • Validates tournament exists and is joinable (status is OPEN or IN_PROGRESS, seats available).
    • Checks user wallet has at least entryFee points.
    • Debits entryFee points from wallet, appends a Ledger entry of type SPEND with note "Tournament entry fee" (one entry per attempt).
    • Increments collectedPoints on every join.
    • Increments numberOfPlayers only on the first join of a user for that tournament.
    • Ensures a Leaderboard entry exists for the user (score initialized to 0 on first creation) so score submissions can overwrite reliably.
    • Replays allowed: user may re-join after a 30 second cooldown from their last join attempt.
    • Idempotent: if called multiple times for the same (user, tournament), it returns success without additional charge.
  • Response 200:
  {
"ok": true,
"wallet": { "availableZishPoints": 30 },
"tournament": { "_id": "66f...1001", "numberOfPlayers": 5, "collectedPoints": 50 },
"ledger": { "_id": "66f...abcd", "type": "SPEND", "amount": 10 }
}
  • Errors:
    • 400: { "error": "Invalid tournament id" } | { "error": "Tournament is not open for joining" } | { "error": "Wallet not found" }
    • 401: { "error": "Unauthorized", "message": "Login required" }
    • 402: { "error": "INSUFFICIENT_FUNDS", "required": 10, "balance": 3 }
    • 404: { "error": "Tournament not found" }
    • 500: { "error": "Failed to join tournament" }

Replay cooldown error example:

{ "error": "COOLDOWN_ACTIVE", "retryAfterSeconds": 12 }

**Notes**

- All IDs are strings.
- No populate on tournament responses.
- Wallet must exist beforehand; top-ups handled elsewhere.

**List Participants**

- **Method:** GET
- **Path:** `/api/v1/tournaments/:id/participants`
- **Auth:** required
- **Response 200:**
{ "data": [ { "username": "SilverFox57", "avatar": null, "score": 123 }, ... ] }
- **Errors:**
- 400: { "error": "Invalid tournament id" }
- 500: { "error": "Failed to fetch participants" }

**Submit Score (Best Score Tracking)**

- **Method:** POST
- **Path:** `/api/v1/tournaments/:id/score`
- **Auth:** required
- **Body:**
- `score`: number (required; must be positive, 0 is invalid)
- `avatar`: string (optional) — snapshot or URL for display
- **Behavior:**
- Requires a prior join (i.e., a `Ledger` SPEND entry for the tournament).
- **Best Score Logic:** Lower scores are better. The score will only be updated if the new score is lower than the previous score.
- If the previous score was 0 (invalid), any valid positive score will replace it.
- If the new score is not better than the existing score, the previous score remains intact.
- Score of 0 is considered invalid and will be rejected.
- Username is resolved from the `User` profile; avatar support pending a user field.
- **Response 200:**
{ "ok": true, "participant": { "username": "SilverFox57", "avatar": null, "score": 321 } }
- **Errors:**
- 400: { "error": "Invalid tournament id" } | { "error": "Invalid score" } | { "error": "Score of 0 is invalid" } | { "error": "Join required before submitting score" }
- 401: { "error": "Unauthorized", "message": "Login required" }
- 500: { "error": "Failed to submit score" }

**My Joined Tournaments**

- **Method:** GET
- **Path:** `/api/v1/tournaments/joined`
- **Auth:** required
- **Behavior:**
- Returns tournaments that the authenticated user has enrolled in (detected via `Ledger` SPEND entries).
- Each item includes the populated `tournament` (with `product` and `game` populated), the `product`, the `game`, the current user's own `leaderboard` entry for that (product, game) pair, and the current `user` profile.
- The `leaderboard` object only contains this user's score for that product/game; it is not the full leaderboard list.
- **Response 200:** Array of items (example):
[
{
"tournament": {
"_id": "66f000000000000000001001",
"status": "OPEN",
"entryFee": 10,
"rules": "Finish in time. No exploits.",
"startAt": "2025-09-10T10:00:00.000Z",
"endedAt": null,
"totalSeats": 10,
"numberOfPlayers": 4,
"expectedPlayers": 10,
"expectedPoints": 100,
"collectedPoints": 40,
"product": {
"_id": "66f000000000000000000001",
"name": "Wireless Controller",
"description": "Pro-grade wireless controller with haptics",
"price": 5999,
"category": "ACCESSORY",
"user": "0192059b-4d7f-7f65-b7d0-3a9ad1d5a1de",
"images": ["https://cdn.example.com/p/ctrl-1.jpg"],
"quantity": 5,
"condition": "New",
"createdAt": "2025-09-08T12:30:11.222Z",
"updatedAt": "2025-09-08T12:30:11.222Z"
},
"game": {
"_id": "66f000000000000000000101",
"name": "Space Dash",
"description": "Dodge and sprint in space!",
"tabcode": "spacedash",
"thumbnail": "https://cdn.example.com/games/spacedash.png",
"status": "PUBLISHED",
"createdAt": "2025-08-20T09:00:00.000Z",
"updatedAt": "2025-08-25T09:00:00.000Z"
},
"createdAt": "2025-09-09T10:00:00.000Z",
"updatedAt": "2025-09-09T10:00:00.000Z"
},
"product": {
"_id": "66f000000000000000000001",
"name": "Wireless Controller",
"description": "Pro-grade wireless controller with haptics",
"price": 5999,
"category": "ACCESSORY",
"user": "0192059b-4d7f-7f65-b7d0-3a9ad1d5a1de",
"images": ["https://cdn.example.com/p/ctrl-1.jpg"],
"quantity": 5,
"condition": "New",
"createdAt": "2025-09-08T12:30:11.222Z",
"updatedAt": "2025-09-08T12:30:11.222Z"
},
"game": {
"_id": "66f000000000000000000101",
"name": "Space Dash",
"description": "Dodge and sprint in space!",
"tabcode": "spacedash",
"thumbnail": "https://cdn.example.com/games/spacedash.png",
"status": "PUBLISHED",
"createdAt": "2025-08-20T09:00:00.000Z",
"updatedAt": "2025-08-25T09:00:00.000Z"
},
"leaderboard": { "_id": "66f00000000000000000lb01", "score": 123 },
"user": { "_id": "0192059b-4d7f-7f65-b7d0-3a9ad1d5a1de", "username": "SilverFox57", "avatar": null, "verified": false }
}

]

  • Errors:
    • 401: { "error": "Unauthorized", "message": "Login required" }
    • 500: { "error": "Failed to fetch joined products" }

Notes

  • Seat handling: When seats are full, new first-time join attempts return 400 and the tournament is marked OVER with endedAt set. When the last seat is filled on a successful join, the tournament is also marked OVER.

Cancel (Early Termination by Seller)

  • Method: POST
  • Path: /api/v1/tournaments/cancel
  • Auth: required (seller/owner only)
  • Body:
    • product: string (required; product id associated with the tournament)
    • reason: string (optional; up to 500 chars)
  • Behavior:
    • Finds the current active (OPEN/IN_PROGRESS) tournament for the given product.
    • Only the product owner can cancel.
    • Requires early termination to be enabled either via the tournament config (earlyTermination.enabled) or product terms acknowledgement (product.terms.enableEarlyTerminationAck).
    • Requires progress >= threshold: (numberOfPlayers / expectedPlayers) * 100 >= earlyTermination.thresholdPct (default 80).
    • On success, sets status to OVER, stamps endedAt, sets cancellationReason, and zeroes totalSeats.
  • Response 200:
    {
    "ok": true,
    "tournament": {
    "_id": "66f...1001",
    "status": "OVER",
    "endedAt": "2025-09-10T12:34:56.789Z",
    "cancellationReason": "terminated early at 85.0%"
    }
    }
  • Errors:
    • 400: { "error": "product is required" }
    • 401: { "error": "UNAUTHORIZED", "message": "Login required" }
    • 403: { "error": "FORBIDDEN" } when caller is not owner OR early termination not enabled OR progress below threshold
    • 404: { "error": "Product not found" } | { "error": "No active tournament for this product" }
    • 500: { "error": "Failed to cancel tournament" }

Frontend guidance (cancel):

  • Show the cancel action only when:
    • User is the product owner, and
    • Server-provided tournament data indicates early termination is enabled (if you fetch tournament details separately), and
    • A client-side progress estimate meets the threshold (optional UI hint; server enforces it regardless).
  • Pass a short reason to help audit. Treat non-200 responses distinctly: surface 403 with the specific message so sellers know why it’s blocked (e.g., progress not met).

Extend End Date

  • Method: POST
  • Path: /api/v1/tournaments/extend
  • Auth: required (seller/owner only)
  • Body:
    • product: string (required; product id)
    • endDate: string (required; ISO date format, e.g., "2025-10-15T23:59:59.000Z")
  • Behavior:
    • Finds the active tournament for the product.
    • Hard limit: Only one extension is allowed per tournament (checks extensionCount < 1).
    • Validates the provided endDate is in the future and after the current endedAt (if exists).
    • Sets the new endedAt, increments extensionCount to 1, and resets any "ending soon" flag.
  • Response 200:
    {
    "ok": true,
    "tournament": {
    "_id": "66f...1001",
    "endedAt": "2025-10-15T23:59:59.000Z",
    "extensionCount": 1
    }
    }
  • Errors:
    • 400: { "error": "product is required" } | { "error": "endDate is required" } | { "error": "Invalid date format" } | { "error": "End date must be in the future" } | { "error": "New end date must be after the current end date" }
    • 401: { "error": "Login required" }
    • 403: { "error": "FORBIDDEN" } (not owner) | { "error": "Extension limit reached. Only one extension allowed per tournament." }
    • 404: { "error": "Product not found" } | { "error": "No active tournament for this product" }
    • 500: { "error": "Failed to extend tournament" }

Frontend guidance (extend):

  • Show an Extend button on the seller's tournament details page near the end date.
  • Use a date picker to allow the seller to select a new end date.
  • Disable the button when:
    • The tournament is already OVER/UNFILLED
    • extensionCount is already 1 (extension already used)
    • The server responds with "Extension limit reached"
  • Display a message showing "Extension used: 1/1" or "Extension available: 0/1" based on extensionCount.