Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions generated/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,11 @@ enum abandoned_matches_constraint {
unique or primary key constraint on columns "id"
"""
abandoned_matches_pkey

"""
unique or primary key constraint on columns "match_id", "steam_id"
"""
abandoned_matches_steam_id_match_id_key
}

"""
Expand Down
2 changes: 1 addition & 1 deletion generated/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1397,7 +1397,7 @@ export interface abandoned_matches_avg_fields {


/** unique or primary key constraints on table "abandoned_matches" */
export type abandoned_matches_constraint = 'abandoned_matches_pkey'
export type abandoned_matches_constraint = 'abandoned_matches_pkey' | 'abandoned_matches_steam_id_match_id_key'


/** aggregate max on columns */
Expand Down
30 changes: 23 additions & 7 deletions hasura/functions/tournaments/can_join_tournament.sql
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,32 @@ CREATE OR REPLACE FUNCTION public.joined_tournament(tournament public.tournament
LANGUAGE plpgsql STABLE
AS $$
DECLARE
on_roster boolean;
_steam_id bigint := (hasura_session ->> 'x-hasura-user-id')::bigint;
BEGIN
SELECT EXISTS (
RETURN EXISTS (
SELECT 1
FROM tournament_team_roster ttr
WHERE
tournament_id = tournament.id
AND player_steam_id = (hasura_session ->> 'x-hasura-user-id')::bigint
) INTO on_roster;

RETURN on_roster;
ttr.tournament_id = tournament.id
AND ttr.player_steam_id = _steam_id
) OR EXISTS (
-- An owner who fields a team without playing on it is still part of
-- the tournament.
SELECT 1
FROM tournament_teams tt
WHERE
tt.tournament_id = tournament.id
AND tt.owner_steam_id = _steam_id
) OR EXISTS (
-- Nobody is on a roster until the draft runs, so in a free agent
-- tournament this is everyone who signed up. Drafted agents are on a
-- roster by then, and withdrawn ones have left the pool.
SELECT 1
FROM tournament_free_agents tfa
WHERE
tfa.tournament_id = tournament.id
AND tfa.player_steam_id = _steam_id
AND tfa.status IN ('registered', 'waitlisted')
);
END;
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "public"."abandoned_matches"
DROP CONSTRAINT IF EXISTS "abandoned_matches_steam_id_match_id_key";
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- One abandon per player per match. The plugin re-arms its disconnect timer on
-- every disconnect and on every map of a series, so the same leave can report
-- itself several times, and sanction_policy_occurrences() counts rows: each
-- duplicate moved the player a rung up the escalating cooldown ladder for a
-- single offense.
--
-- match_id stays nullable (historical rows, and no-shows recorded before a
-- match exists), and postgres treats NULLs as distinct, so those rows are
-- unaffected by the constraint.
DELETE FROM public.abandoned_matches a
USING public.abandoned_matches b
WHERE a.match_id IS NOT NULL
AND a.match_id = b.match_id
AND a.steam_id = b.steam_id
AND (a.abandoned_at, a.id) > (b.abandoned_at, b.id);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'abandoned_matches_steam_id_match_id_key'
) THEN
ALTER TABLE "public"."abandoned_matches"
ADD CONSTRAINT "abandoned_matches_steam_id_match_id_key"
UNIQUE ("steam_id", "match_id");
END IF;
END $$;
28 changes: 28 additions & 0 deletions hasura/triggers/team_roster.sql
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,34 @@ $$;
DROP TRIGGER IF EXISTS tbi_team_roster ON public.team_roster;
CREATE TRIGGER tbi_team_roster BEFORE INSERT ON public.team_roster FOR EACH ROW EXECUTE FUNCTION public.tbi_team_roster();

-- The owner is a team's last line of authority: can_change_team_role and
-- can_remove_from_team both fall back to owner_steam_id, so an owner who walks
-- off the roster leaves a team that only a site admin can manage. Ownership has
-- to be handed over first.
--
-- Deleting the team itself cascades to these rows, and by then the team row is
-- already gone, so that path finds no owner here and passes.
CREATE OR REPLACE FUNCTION public.tbd_team_roster() RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM teams t
WHERE t.id = OLD.team_id
AND t.owner_steam_id = OLD.player_steam_id
) THEN
RAISE EXCEPTION USING ERRCODE = '22000',
MESSAGE = 'The team owner cannot leave the team; transfer ownership first';
END IF;

RETURN OLD;
END;
$$;

DROP TRIGGER IF EXISTS tbd_team_roster ON public.team_roster;
CREATE TRIGGER tbd_team_roster BEFORE DELETE ON public.team_roster FOR EACH ROW EXECUTE FUNCTION public.tbd_team_roster();

CREATE OR REPLACE FUNCTION public.tad_team_roster() RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
Expand Down
201 changes: 201 additions & 0 deletions src/chat/chat.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,110 @@ describe("ChatService direct messages", () => {

// Which matches this player belongs to, by id.
let myMatches: string[];
// The one tournament the fake knows about, and who is attached to it.
let tournament: {
organizers: string[];
teamOwners: string[];
roster: string[];
freeAgents: Array<{ steam_id: string; status: string }>;
};
// Who the organizers' role gate admits.
let staff: string[];

// Answers the access query the way the database would, so the assertions are
// about who gets in rather than about the shape of the query.
const tournamentAdmits = (where: any) =>
(where._or ?? []).some((branch: any) => {
if (branch.is_organizer) {
return tournament.organizers.includes(String(steamIdIn(branch)));
}

if (branch.teams) {
return branch.teams._or.some((teamBranch: any) => {
const steamId = String(steamIdIn(teamBranch));
return teamBranch.owner_steam_id
? tournament.teamOwners.includes(steamId)
: tournament.roster.includes(steamId);
});
}

if (branch.free_agents) {
const steamId = String(branch.free_agents.player_steam_id._eq);
const statuses = branch.free_agents.status?._in ?? [];

return tournament.freeAgents.some(
(freeAgent) =>
freeAgent.steam_id === steamId &&
statuses.includes(freeAgent.status),
);
}

return false;
});

// the steam id buried anywhere in one branch of the _or
const steamIdIn = (branch: any): string | undefined => {
if (typeof branch !== "object" || branch === null) {
return undefined;
}

for (const [key, value] of Object.entries<any>(branch)) {
if (key.endsWith("steam_id") && value?._eq !== undefined) {
return String(value._eq);
}

const nested = Array.isArray(value)
? value.map(steamIdIn).find(Boolean)
: steamIdIn(value);

if (nested) {
return nested;
}
}

return undefined;
};

const hasuraService = {
query: jest.fn(async (query: any) => {
if (query.tournaments) {
return {
tournaments: tournamentAdmits(query.tournaments.__args.where)
? [{ id: "t-1" }]
: [],
};
}

if (query.tournaments_by_pk) {
return {
tournaments_by_pk: {
organizer_steam_id: tournament.organizers[0],
organizers: tournament.organizers
.slice(1)
.map((steam_id) => ({ steam_id })),
teams: [
{
owner_steam_id: tournament.teamOwners[0],
roster: tournament.roster.map((player_steam_id) => ({
player_steam_id,
})),
},
],
free_agents: tournament.freeAgents
.filter((freeAgent) =>
(
query.tournaments_by_pk.free_agents?.__args?.where?.status
?._in ?? []
).includes(freeAgent.status),
)
.map((freeAgent) => ({
player_steam_id: freeAgent.steam_id,
status: freeAgent.status,
})),
},
};
}

if (query.matches_by_pk) {
return myMatches.includes(query.matches_by_pk.__args.id)
? {
Expand Down Expand Up @@ -107,6 +206,12 @@ describe("ChatService direct messages", () => {
jest.clearAllMocks();
acceptedFriendships = [[ME, FRIEND]];
myMatches = ["m-1"];
tournament = {
organizers: [STRANGER],
teamOwners: [],
roster: [],
freeAgents: [],
};
staff = [];
role = "user";
queries = [];
Expand Down Expand Up @@ -187,6 +292,102 @@ describe("ChatService direct messages", () => {
});
});

describe("tournament chat", () => {
const join = async (steamId: string) => {
await service.joinMatchLobby(
client(steamId),
ChatLobbyType.Tournament,
"t-1",
);
return joined();
};

it("lets a player on a tournament team roster in", async () => {
tournament.roster = [ME];

expect(await join(ME)).toBe(true);
});

it("lets a registered free agent in", async () => {
// in a free-agent tournament nobody is on a roster until the draft, so
// this is everyone who signed up
tournament.freeAgents = [{ steam_id: ME, status: "registered" }];

expect(await join(ME)).toBe(true);
});

it("lets a waitlisted free agent in", async () => {
tournament.freeAgents = [{ steam_id: ME, status: "waitlisted" }];

expect(await join(ME)).toBe(true);
});

it("keeps a withdrawn free agent out", async () => {
tournament.freeAgents = [{ steam_id: ME, status: "withdrawn" }];

expect(await join(ME)).toBe(false);
});

it("keeps an unrelated player out", async () => {
expect(await join(ME)).toBe(false);
});

// the message write is the awaited step; the broadcast after it is
// deliberately fire-and-forget
const posted = () =>
redis.hset.mock.calls.some(([key]) => key === "chat_tournament_t-1");

it("stops a free agent who withdrew from posting", async () => {
// the room's membership lives in redis for 24h, so leaving the pool has
// to be re-checked when the message is sent, not only when joining
redis.hget.mockResolvedValue(JSON.stringify({ steam_id: ME }));
tournament.freeAgents = [{ steam_id: ME, status: "withdrawn" }];

await service.sendMessageToChat(
ChatLobbyType.Tournament,
"t-1",
{ steam_id: ME, name: "Someone", role } as any,
"still here",
);

expect(posted()).toBe(false);
});

it("lets a registered free agent post", async () => {
redis.hget.mockResolvedValue(JSON.stringify({ steam_id: ME }));
tournament.freeAgents = [{ steam_id: ME, status: "registered" }];

await service.sendMessageToChat(
ChatLobbyType.Tournament,
"t-1",
{ steam_id: ME, name: "Someone", role } as any,
"hello",
);

expect(posted()).toBe(true);
});

it("notifies free agents as well as rostered players", async () => {
tournament.organizers = [STRANGER];
tournament.teamOwners = [FRIEND];
tournament.roster = [FRIEND];
tournament.freeAgents = [
{ steam_id: ME, status: "registered" },
{ steam_id: "76561198000000004", status: "withdrawn" },
];

const recipients = await service.getLobbyMemberSteamIds(
ChatLobbyType.Tournament,
"t-1",
);

expect(recipients).toContain(ME);
expect(recipients).toContain(FRIEND);
expect(recipients).toContain(STRANGER);
expect(recipients).not.toContain("76561198000000004");
});
});

describe("rosters", () => {
it("resolves both parties of a conversation", async () => {
expect(
Expand Down
Loading
Loading