Tournament Timing Rules

Interactive visualizer for Lucra pool tournament lifecycle & date filtering logic

Timeline Simulator

Drag the markers to explore timing scenarios
new
Proposed: sign_up window
Explore the signup start/end feature
Visible in Feed
Can Join
Can Submit Scores
See all score surfaces for details
Score submission has multiple API surfaces with different validation rules. The key differentiator is how the matchup is looked up: direct by ID (lenient) vs query by metadata/gameId (strict). Any endpoint that receives a matchupId takes the lenient path.

By Matchup ID — Direct Lookup by matchupId (Lenient)

These surfaces fetch the matchup via getByPk(matchupId) or getById(matchupId) — no query filters. The only timing validation is validateTournamentMatchupHasStarted().

REST PUT / PATCH /api/rest/pool-tournament/:matchupId/users-scores
Tenant integration endpoint. Accepts an array of user scores for a known matchup ID. Auth: API Key.
Status Check
NONE
expires_at Check
NONE
starts_at Check
NOW >= starts_at
> functions/remote-action-tenants-api/src/handlers/matchup-pool-tournaments/update-scores.ts
> Calls MatchupPoolTournamentService.getById(matchupId) — direct lookup, no filters
> Then MatchupPoolTournamentService.updateUsersScores() :1033-1062
> Validation: set-scores-validations.ts:6-13validateTournamentMatchupHasStarted()
GraphQL tournaments_update_user_score
Hasura action for client-side score submission. Takes a matchup ID directly. Auth: User JWT.
Status Check
NONE
expires_at Check
NONE
starts_at Check
NOW >= starts_at
> functions/remote-action-matchup/src/handlers/tournaments/update-score.ts
> Calls MatchupPoolTournamentService.getById(matchupId) — direct lookup, no filters
> Then MatchupPoolTournamentService.updateUsersScores()
> Hasura action enum: UpdateUserScore_PoolTournament
By Matchup ID accepts scores on expired, closed, cancelled, and disputed tournaments — the only gate is starts_at. If starts_at is NULL, there are zero timing checks.

By Matchup Metadata — Query-based Lookup (Strict)

These surfaces resolve matchups via findOpenMatchups() or findOpenMatchupsByMetadata(), which enforce status and expiration filters in the query. However, if a matchupId is provided as an identifier, the lookup falls back to getByPk() and bypasses all query filters, behaving identically to the By Matchup ID path.

Key gotcha: matchupId in a metadata endpoint = no protection

Every "By Matchup Metadata" endpoint below also accepts a matchupId field. When provided, the metadata/gameId/locationId fields are ignored and the system calls getByPk(matchupId) directly — skipping all status and expiration filters. The request silently behaves as a "By Matchup ID" call. This means the strict protections shown below only apply when the caller does not pass a matchupId.

REST POST /api/rest/pool-tournament/user-score
Tenant integration endpoint. Matches a user to tournament(s) via metadata, gameId, locationId, or matchupId. Auth: API Key.
Lookup depends on identifier provided:
Status Check
via query
Open/Confirmed/Locked/Pending
expires_at Check
via query
expires_at > NOW()
starts_at Check
NOW >= starts_at
in updateUsersScores()
If matchupId is provided instead of metadata/gameId, lookup uses getByPk() — status and expiration checks are bypassed.
> functions/remote-action-tenants-api/src/handlers/matchup-pool-tournaments/update-scores-matching.ts
> Calls MatchupScoreIngestionService.ingestTournamentScores() :98-186
> Matchup resolution: matchup-matching.service.ts:searchTournamentsAndGamesIds() :92-159
> Query filters: matchup.service.ts:findOpenMatchupsByMetadata() :343-388
REST POST /api/rest/user-score
Generic score ingestion endpoint. Handles both pool tournaments and recreational games. Auth: API Key.
Status Check
via query
Open/Confirmed/Locked/Pending
expires_at Check
via query
expires_at > NOW()
starts_at Check
NOW >= starts_at
in updateUsersScores()
Same bypass: if matchupId is provided, getByPk() is used — no query filters applied.
> functions/remote-action-tenants-api/src/handlers/handle-score-ingestion.ts
> Calls MatchupScoreIngestionService.ingestGeneralScores() for both tournament + rec game types
> Same resolution chain as B1 above
GraphQL tournaments_update_user_score_matching
Hasura action for client-side matching score submission. Resolves matchup from identifiers. Auth: User JWT.
Status Check
via query
Open/Confirmed/Locked/Pending
expires_at Check
via query
expires_at > NOW()
starts_at Check
NOW >= starts_at
in updateUsersScores()
Same bypass: if matchupId is provided, getByPk() is used — no query filters applied.
> functions/remote-action-matchup/src/handlers/tournaments/update-score-matching.ts
> Calls MatchupScoreIngestionService.ingestTournamentScores()
> Hasura action enum: UpdateUserScoreMatching_PoolTournament
REST POST /api/rest/recreational-games/user-score
Recreational game score endpoint. Same matching flow as pool tournament but for RecreationalGame type. Auth: API Key.
Status Check
via query
expires_at Check
via query
starts_at Check
in update
> functions/remote-action-tenants-api/src/handlers/recreational-games/update-scores-matching.ts
> Calls MatchupScoreIngestionService.ingestRecreationalGameScores()

Comparison Matrix

Surface Lookup Status expires_at starts_at Auth
PUT .../pool-tournament/:id/users-scores by ID NONE NONE checked API Key
tournaments_update_user_score by ID NONE NONE checked User JWT
POST .../pool-tournament/user-score query* filtered filtered checked API Key
POST .../user-score query* filtered filtered checked API Key
tournaments_update_user_score_matching query* filtered filtered checked User JWT
POST .../recreational-games/user-score query* filtered filtered checked API Key

* Every "query" row above becomes identical to the "by ID" rows when a matchupId is passed — all status and expiration filters are bypassed via getByPk(). The strict checks only apply when matching by metadata, gameId, or locationId.

Shared Validation: updateUsersScores()

All score surfaces eventually call MatchupPoolTournamentService.updateUsersScores() which runs one timing check:

validateTournamentMatchupHasStarted(matchup)
  if starts_at is NULL → skip (allow)
  if NOW() < starts_atthrow NOT_STARTED
  if NOW() >= starts_atpass
> layers/.../matchup/utils/set-scores-validations.ts:6-13
> Called from matchup-pool-tournament.service.ts:1039

Matchup Resolution: searchTournamentsAndGamesIds()

By Matchup Metadata surfaces resolve matchups through MatchupMatchingService. The identifier provided determines the code path:

?Which identifier was provided?
AmatchupIdgetByPk(matchupId)NO filters, returns any matchup
BmatchupMetadatafindOpenMatchupsByMetadata()status + expiration filtered
CgameId / locationIdfindOpenMatchups()status + expiration filtered
> layers/.../matchup/matchup-matching.service.ts:92-159
> Query filters at matchup.service.ts:findOpenMatchups():343-370 and :findOpenMatchupsByMetadata():372-388

Rules Matrix

All possible timing scenarios. Score columns reflect the two lookup methods. See Score Surfaces tab for details.

Scenario NOW vs starts_at NOW vs expires_at Visible? Can Join? Score (by ID) Score (by query)

Visibility Check Flow

get-tournaments.ts → matchup-pool-tournament.service.ts
1Request hits featured_games_page_items action
2expiresAt = new Date().toISOString()
3Filter: status IN [Open, Confirmed]
4Filter: expires_at >= NOW()
5Location filter (if locationId provided)
6Sort by join status + pinned + privacy + dates

Note: starts_at is used only for sorting joined tournaments, never for filtering visibility. When includeClosedTournaments=true, status Closed is also included.

Join Validation Flow

matchup-validation.service.ts:115-167
1Status must be Open or Confirmed (PoolTournament)
↓ pass
2Creator cannot join own matchup
↓ pass
3User cannot join twice
↓ pass
4max_participants check
↓ pass
5NOW() < expires_at (if expires_at is set)
↓ pass
6Join allowed

Note: starts_at is NOT checked for joining. A user can join a tournament that hasn't started yet.

Score Submission Flows

Score submission has multiple code paths depending on the surface and identifier used. See the Score Surfaces tab for the full breakdown.

By Matchup ID

update-scores.ts / update-score.ts
1getById(matchupId) — no filters
2validateTournamentMatchupHasStarted()
↓ pass
3Score accepted
No status or expiration check. Expired/closed tournaments accept scores.

By Matchup Metadata

matchup.service.ts:343-388
1status IN [Open, Confirmed, Locked, PendingOutcomes]
2expires_at > NOW()
3validateTournamentMatchupHasStarted()
↓ pass
4Score accepted
Falls back to the By Matchup ID path if a matchupId is provided directly.

Key Files & Responsibilities

Score submission files are detailed in the Score Surfaces tab.

FileRoleKey Logic
Visibility & Feed
metadata/actions.yamlAction definitionRoutes featured_games_page_items to handler
functions/remote-action-recommended-matchups/src/index.tsHandler routerAction switch dispatch
.../handlers/handle-featured-games-page-items.tsMain handlerCalls getTournaments() + getFeaturedGames()
.../repository/get-tournaments.tsTournament queryexpiresAt = new Date().toISOString(), sorting logic
Joining
layers/.../matchup-validation.service.tsJoin validationvalidateForJoining() :115-167
Score Submission — Handlers (see Score Surfaces for category breakdown)
functions/remote-action-tenants-api/.../update-scores.tsREST handlerPUT /pool-tournament/:id/users-scores
functions/remote-action-tenants-api/.../update-scores-matching.tsREST handlerPOST /pool-tournament/user-score
functions/remote-action-tenants-api/.../handle-score-ingestion.tsREST handlerPOST /user-score
functions/remote-action-matchup/.../update-score.tsGraphQL handlertournaments_update_user_score
functions/remote-action-matchup/.../update-score-matching.tsGraphQL handlertournaments_update_user_score_matching
functions/remote-action-tenants-api/.../recreational-games/update-scores-matching.tsREST handlerPOST /recreational-games/user-score
Score Submission — Shared Services
layers/.../matchup-pool-tournament.service.tsCore serviceupdateUsersScores() :1033-1062, findBy(), getQueryFilters()
layers/.../matchup.service.tsBase matchup servicefindOpenMatchups() :343-370, findOpenMatchupsByMetadata() :372-388
layers/.../matchup-score-ingestion.service.tsScore ingestioningestTournamentScores() :98-186, ingestGeneralScores()
layers/.../matchup-matching.service.tsMatchup resolutionsearchTournamentsAndGamesIds() :92-159, findTournamentsForUser()
layers/.../utils/set-scores-validations.tsScore validationvalidateTournamentMatchupHasStarted() :6-13
Creation & Data Model
layers/.../utils/pool-tournament-mappers.utils.type.tsDTO mappingstarts_at defaults to today, expires_at pass-through
layers/.../dto/base.tsInput validationBoth dates @IsOptional() @IsDateString()
layers/.../entities/base-matchup.entity.tsEntity definitionAll date fields optional strings

Date Field Defaults on Creation

FieldPool Tournament DefaultSource
created_atAuto-set by databasePostgreSQL default
starts_atdto.startsAt ?? DateTime.now().toISODate() (today)pool-tournament-mappers.utils.type.ts:L147-200
expires_atdto.expiresAt (no default — can be NULL)pool-tournament-mappers.utils.type.ts:L147-200
closed_atSet when tournament closes
No validation prevents expires_at < starts_at — this creates a misconfigured tournament that expires before it starts, making it unscoreable.