From 6750475ed6ba76dc4bf392ffd8885ee1f97f5693 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sun, 12 Jul 2026 08:26:50 -0400 Subject: [PATCH] bug: enforce having events having a start and end time --- .../leagues/league_season_lifecycle.sql | 8 +++--- hasura/functions/players/get_player_elo.sql | 25 +++++++++++-------- .../down.sql | 5 ++++ .../up.sql | 22 ++++++++++++++++ hasura/triggers/event_match_links.sql | 3 +++ hasura/triggers/league_seasons.sql | 10 ++++++-- hasura/views/v_event_matches.sql | 22 ++++++++++++---- 7 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 hasura/migrations/default/1870000000300_events_require_start_date/down.sql create mode 100644 hasura/migrations/default/1870000000300_events_require_start_date/up.sql diff --git a/hasura/functions/leagues/league_season_lifecycle.sql b/hasura/functions/leagues/league_season_lifecycle.sql index 35422f92..442fb3e5 100644 --- a/hasura/functions/leagues/league_season_lifecycle.sql +++ b/hasura/functions/leagues/league_season_lifecycle.sql @@ -342,10 +342,12 @@ BEGIN DELETE FROM public.league_relegation_playoffs WHERE league_season_id = _league_season_id; DELETE FROM public.league_team_movements WHERE league_season_id = _league_season_id; - -- Revive to Live; tau_league_seasons re-runs start_league_season, which - -- rebuilds the division tournaments from the same Approved team seasons. + -- Reset to Setup so the admin can reconfigure (dates, divisions, teams) and + -- run the season again from scratch; the torn-down tournaments are rebuilt + -- when it next reaches Live. The bypass lets the Canceled -> Setup transition + -- past the terminal-status and source-status guards. PERFORM set_config('fivestack.league_restart', 'true', true); - UPDATE public.league_seasons SET status = 'Live' WHERE id = _league_season_id; + UPDATE public.league_seasons SET status = 'Setup' WHERE id = _league_season_id; PERFORM set_config('fivestack.league_restart', 'false', true); PERFORM set_config('fivestack.league_cascade', 'false', true); diff --git a/hasura/functions/players/get_player_elo.sql b/hasura/functions/players/get_player_elo.sql index f4879b68..7c9d828e 100644 --- a/hasura/functions/players/get_player_elo.sql +++ b/hasura/functions/players/get_player_elo.sql @@ -46,24 +46,29 @@ END; $$; -- Season ELO: latest ELO tagged to this season (season_id-indexed lookup; rows are --- populated by the season backfill). +-- populated by the season backfill). Falls back to the player's lifetime ELO when +-- they have no rating in this season yet (e.g. the start of a new season) so the UI +-- shows their last known ELO instead of a blank. CREATE OR REPLACE FUNCTION public.get_player_season_elo_by_type(player public.players, _type text, _season_id UUID) RETURNS numeric LANGUAGE plpgsql STABLE AS $$ DECLARE elo_value numeric; BEGIN - IF _season_id IS NULL THEN - RETURN NULL; + IF _season_id IS NOT NULL THEN + SELECT current INTO elo_value + FROM player_elo + WHERE steam_id = player.steam_id + AND "type" = _type + AND season_id = _season_id + ORDER BY created_at DESC + LIMIT 1; END IF; - SELECT current INTO elo_value - FROM player_elo - WHERE steam_id = player.steam_id - AND "type" = _type - AND season_id = _season_id - ORDER BY created_at DESC - LIMIT 1; + -- No season rating yet: fall back to lifetime (global) ELO for this type. + IF elo_value IS NULL THEN + elo_value := get_player_elo_by_type(player, _type); + END IF; RETURN elo_value; END; diff --git a/hasura/migrations/default/1870000000300_events_require_start_date/down.sql b/hasura/migrations/default/1870000000300_events_require_start_date/down.sql new file mode 100644 index 00000000..0de83a92 --- /dev/null +++ b/hasura/migrations/default/1870000000300_events_require_start_date/down.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.events + ALTER COLUMN starts_at DROP NOT NULL; + +ALTER TABLE public.events + ALTER COLUMN starts_at DROP DEFAULT; diff --git a/hasura/migrations/default/1870000000300_events_require_start_date/up.sql b/hasura/migrations/default/1870000000300_events_require_start_date/up.sql new file mode 100644 index 00000000..ecf89f63 --- /dev/null +++ b/hasura/migrations/default/1870000000300_events_require_start_date/up.sql @@ -0,0 +1,22 @@ +-- Every event must have a start date. The start date is the lower bound of +-- the event window in public.v_event_matches: a match belongs to the event +-- only when it falls in [starts_at, ends_at + 1 day) AND involves a tracked +-- team/player (or sits in an attached tournament's bracket). Without a start +-- date the window opened to -infinity and pulled in an attached team's or +-- player's entire match history (lifetime stats/highlights). +-- +-- Backfill existing dateless events to today, then enforce NOT NULL. Setting +-- starts_at fires tg_events_sync_match_links (AFTER UPDATE OF starts_at), so +-- event_match_links is re-derived for each backfilled event as part of this +-- migration. +UPDATE public.events + SET starts_at = date_trunc('day', now()) + WHERE starts_at IS NULL; + +-- New events without an explicit start default to today rather than being +-- rejected outright; the event form also requires the field. +ALTER TABLE public.events + ALTER COLUMN starts_at SET DEFAULT date_trunc('day', now()); + +ALTER TABLE public.events + ALTER COLUMN starts_at SET NOT NULL; diff --git a/hasura/triggers/event_match_links.sql b/hasura/triggers/event_match_links.sql index 7a6dd25a..3c4934b5 100644 --- a/hasura/triggers/event_match_links.sql +++ b/hasura/triggers/event_match_links.sql @@ -118,6 +118,9 @@ CREATE TRIGGER tg_mlp_sync_event_match_links -- Full backfill/prune. Runs only when this file's digest changes; keeps the -- table exact after upgrades that alter the derivation. +-- Re-run trigger: v_event_matches now bounds team/player-derived matches to +-- the event window (missing start date -> no windowed matches), so this prune +-- must drop the stale lifetime links a previous unbounded window inserted. DELETE FROM public.event_match_links l WHERE NOT EXISTS ( SELECT 1 FROM public.v_event_matches v diff --git a/hasura/triggers/league_seasons.sql b/hasura/triggers/league_seasons.sql index 2f961545..2058b704 100644 --- a/hasura/triggers/league_seasons.sql +++ b/hasura/triggers/league_seasons.sql @@ -87,7 +87,7 @@ BEGIN END IF; IF NEW.status IS DISTINCT FROM OLD.status THEN - -- restart_league_season revives a Canceled season back to Live; it sets + -- restart_league_season resets a Canceled season back to Setup; it sets -- this bypass so the terminal-status and source-status guards stand aside. IF OLD.status IN ('Finished', 'Canceled') AND current_setting('fivestack.league_restart', true) IS DISTINCT FROM 'true' THEN @@ -163,7 +163,10 @@ BEGIN RAISE EXCEPTION USING ERRCODE = '22000', MESSAGE = 'Season can only finish from Live or Playoffs'; END IF; WHEN 'Setup' THEN - IF OLD.status != 'RegistrationOpen' THEN + -- Normally only a re-opened registration reverts to Setup; a + -- restart also resets a Canceled season back to Setup. + IF OLD.status != 'RegistrationOpen' + AND current_setting('fivestack.league_restart', true) IS DISTINCT FROM 'true' THEN RAISE EXCEPTION USING ERRCODE = '22000', MESSAGE = 'Season can only return to Setup from RegistrationOpen'; END IF; WHEN 'Canceled' THEN @@ -174,7 +177,10 @@ BEGIN END IF; -- Re-validate scheduling when the window moves (or a season is revived). + -- A restart to Setup skips this: the whole point is to reconfigure, and the + -- old (possibly stale/overlapping) dates get fixed before it runs again. IF NEW.status NOT IN ('Canceled', 'Finished') + AND current_setting('fivestack.league_restart', true) IS DISTINCT FROM 'true' AND (NEW.signup_opens_at IS DISTINCT FROM OLD.signup_opens_at OR NEW.starts_at IS DISTINCT FROM OLD.starts_at OR NEW.match_weeks_count IS DISTINCT FROM OLD.match_weeks_count diff --git a/hasura/views/v_event_matches.sql b/hasura/views/v_event_matches.sql index cb67a825..df2d7784 100644 --- a/hasura/views/v_event_matches.sql +++ b/hasura/views/v_event_matches.sql @@ -10,16 +10,28 @@ -- - one of its lineup players is an attached player and the match falls -- inside the event window. -- --- The window end gets a day of grace so matches that start on the final --- evening still count when ends_at is set to midday; missing dates leave --- that side of the window open. +-- The window is [starts_at, end + 1 day): +-- - window_start is the event's starts_at. An event with NO start date is +-- excluded from the windowed (team/player) branches entirely — otherwise +-- a missing start defaulted to -infinity and pulled in an attached team's +-- or player's ENTIRE match history (lifetime stats/highlights instead of +-- just this event's). Dateless events still get their attached +-- tournaments' bracket matches (that branch needs no window). +-- - window_end is ends_at + 1 day, or, for an ongoing event with no end +-- date, now() + 1 day so every match played up to today is captured. The +-- day of grace lets matches that start on the final evening still count +-- when ends_at is set to midday. +-- event_match_links materializes this view via triggers; a match completed +-- while an event is ongoing is re-derived on its started_at update (see +-- hasura/triggers/event_match_links.sql), so now() here stays accurate. CREATE OR REPLACE VIEW public.v_event_matches AS WITH windowed AS ( SELECT e.id AS event_id, - COALESCE(e.starts_at, '-infinity'::timestamptz) AS window_start, - COALESCE(e.ends_at + interval '1 day', 'infinity'::timestamptz) AS window_end + e.starts_at AS window_start, + COALESCE(e.ends_at, now()) + interval '1 day' AS window_end FROM events e + WHERE e.starts_at IS NOT NULL ) SELECT DISTINCT s.event_id, s.match_id FROM (