From 98006b3cb088a8e0532d4bdc203052440e34f976 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 10:30:39 -0400 Subject: [PATCH 01/10] Let 5stack Ranks satisfy a plugin's need for the guidelines off Ranks already turns FollowCS2ServerGuidelines off to render ranks in-game, and the ban Valve threatens is against the Steam account rather than one server -- so once ranks is on, that risk is taken deployment-wide and a plugin that needs the guidelines off already has them off. Also closes the gap it exposes: SHOW_ELO_RANKS is only ever set on on-demand match pods, so a dedicated server loading such a plugin kept the guidelines on no matter what. It resolves from the same place both pod builders read now. --- src/game-plugins/game-modes.service.ts | 27 ++++++++++++++------ test/game-mode-resolution.spec.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/game-plugins/game-modes.service.ts b/src/game-plugins/game-modes.service.ts index e3898854..7da89747 100644 --- a/src/game-plugins/game-modes.service.ts +++ b/src/game-plugins/game-modes.service.ts @@ -174,6 +174,11 @@ export class GameModesService { // saying the plugin cannot work without it, and the operator having said yes // for that plugin. Decided from the plugins that are actually going to load, // so a mode that does not select the plugin leaves the server compliant. + // + // 5stack Ranks is the exception, because it already turns the same setting + // off to render ranks in-game. The ban it risks is against the Steam account, + // not the server, so once ranks is on that risk is taken deployment-wide and + // asking a second time per plugin would be asking about nothing. private async withServerGuidelines( mode: ResolvedGameMode | null, ): Promise { @@ -187,13 +192,21 @@ export class GameModesService { .map((entry) => entry.split("@")[0]); const [row] = await this.postgres.query>( - `SELECT EXISTS ( - SELECT 1 - FROM game_plugin_installs i - INNER JOIN game_plugins p ON p.slug = i.plugin_slug - WHERE i.plugin_slug = ANY($1::text[]) - AND i.disable_server_guidelines = true - AND p.requires_server_guidelines_disabled = true + `SELECT ( + EXISTS ( + SELECT 1 + FROM game_plugin_installs i + INNER JOIN game_plugins p ON p.slug = i.plugin_slug + WHERE i.plugin_slug = ANY($1::text[]) + AND i.disable_server_guidelines = true + AND p.requires_server_guidelines_disabled = true + ) + OR EXISTS ( + SELECT 1 FROM settings + WHERE name IN ('fivestack_ranks_matches', + 'fivestack_ranks_tournaments') + AND value = 'true' + ) ) AS disable`, [slugs], ); diff --git a/test/game-mode-resolution.spec.ts b/test/game-mode-resolution.spec.ts index 377807b6..0753a1ab 100644 --- a/test/game-mode-resolution.spec.ts +++ b/test/game-mode-resolution.spec.ts @@ -36,6 +36,10 @@ describe("game mode resolution (SQL-driven)", () => { let serverId: string; beforeEach(async () => { + await postgres.query( + `DELETE FROM settings + WHERE name IN ('fivestack_ranks_matches', 'fivestack_ranks_tournaments')`, + ); await postgres.query("DELETE FROM servers"); await postgres.query("DELETE FROM game_server_node_plugins"); await postgres.query("DELETE FROM game_plugin_installs"); @@ -215,6 +219,37 @@ describe("game mode resolution (SQL-driven)", () => { expect(resolved?.disableServerGuidelines).toBe(false); }); + // Ranks flips the same framework setting to render ranks in-game, and the + // ban it risks is against the account rather than the server -- so once it + // is on, a plugin that needs the guidelines off already has them off. + it("comes off for ranks alone, with no per-plugin opt-in", async () => { + await postgres.query( + `INSERT INTO settings (name, value) VALUES ('fivestack_ranks_matches', 'true') + ON CONFLICT (name) DO UPDATE SET value = 'true'`, + ); + + await installed("inventory-simulator"); + await modeWith("inventory-simulator"); + + const resolved = await service.resolveForServer(serverId); + + expect(resolved?.disableServerGuidelines).toBe(true); + }); + + it("stays on while ranks is off and nobody opted in", async () => { + await postgres.query( + `INSERT INTO settings (name, value) VALUES ('fivestack_ranks_matches', 'false') + ON CONFLICT (name) DO UPDATE SET value = 'false'`, + ); + + await installed("inventory-simulator"); + await modeWith("inventory-simulator"); + + const resolved = await service.resolveForServer(serverId); + + expect(resolved?.disableServerGuidelines).toBe(false); + }); + // always_load reaches every server including ranked, so the opt-in has to // follow it there rather than only applying to modes. it("follows an always-load plugin onto a server with no mode", async () => { From c7bed99c8e1f8b9871dc8741d4ad44275eb8d794 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 10:32:37 -0400 Subject: [PATCH 02/10] chore: enrich notiications --- src/awards/awards.service.ts | 10 +- src/demos/demos.controller.ts | 3 + src/invites/invites.controller.ts | 15 +- src/matches/clips/clips.service.ts | 22 +- src/matches/jobs/EventReminders.ts | 20 +- src/matches/jobs/TournamentReminders.ts | 12 +- src/news/news.service.ts | 3 + src/notifications/notifications.service.ts | 67 ++++- .../push/push-notifications.service.spec.ts | 224 +++++++++++++++- .../push/push-notifications.service.ts | 243 +++++++++++++++++- src/steam-presence/steam-presence.service.ts | 49 ++-- src/tournaments/tournaments.controller.ts | 6 + 12 files changed, 624 insertions(+), 50 deletions(-) diff --git a/src/awards/awards.service.ts b/src/awards/awards.service.ts index 3f62c844..b7354fc0 100644 --- a/src/awards/awards.service.ts +++ b/src/awards/awards.service.ts @@ -260,10 +260,11 @@ export class AwardsService { } try { - const [award] = await this.postgres.query>( - `SELECT name FROM public.awards WHERE id = $1::uuid`, - [awardId], - ); + const [award] = await this.postgres.query< + Array<{ name: string; image_url: string | null }> + >(`SELECT name, image_url FROM public.awards WHERE id = $1::uuid`, [ + awardId, + ]); await this.notifications.notifyPlayers("AwardGranted", { title: "Award Received", @@ -273,6 +274,7 @@ export class AwardsService { role: "user", entity_id: recipientId, steamIds, + ...(award?.image_url ? { data: { image: award.image_url } } : {}), }); } catch (error) { this.logger.warn( diff --git a/src/demos/demos.controller.ts b/src/demos/demos.controller.ts index 72d2e31c..18279dca 100644 --- a/src/demos/demos.controller.ts +++ b/src/demos/demos.controller.ts @@ -332,12 +332,15 @@ export class DemosController { return; } + const image = await this.notifications.mapPosterImage({ matchId }); + await this.notifications.notifyPlayers("MatchStatsReady", { title: "Match Stats Ready", message: `Your match stats are ready. View Match`, role: "user", entity_id: matchId, steamIds: players.map((player) => player.steam_id), + ...(image ? { data: { image } } : {}), }); } catch (error) { this.logger.warn( diff --git a/src/invites/invites.controller.ts b/src/invites/invites.controller.ts index 4555e829..48d5865c 100644 --- a/src/invites/invites.controller.ts +++ b/src/invites/invites.controller.ts @@ -29,10 +29,16 @@ export class InvitesController { } const [invite] = await this.postgres.query< - Array<{ steam_id: string; team_name: string; invited_by: string }> + Array<{ + steam_id: string; + team_name: string; + team_avatar: string | null; + invited_by: string; + }> >( `SELECT ti.steam_id::text AS steam_id, t.name AS team_name, + t.avatar_url AS team_avatar, COALESCE(p.name, 'Someone') AS invited_by FROM public.team_invites ti JOIN public.teams t ON t.id = ti.team_id @@ -50,6 +56,7 @@ export class InvitesController { title: "Team Invite", body: `${NotificationsService.escapeHtml(invite.invited_by)} invited you to ${NotificationsService.escapeHtml(invite.team_name)}.`, entityId: data.new.id, + icon: invite.team_avatar, }); } @@ -66,12 +73,14 @@ export class InvitesController { steam_id: string; team_name: string; tournament_name: string; + tournament_logo: string | null; invited_by: string; }> >( `SELECT tti.steam_id::text AS steam_id, tt.name AS team_name, tour.name AS tournament_name, + tour.logo AS tournament_logo, COALESCE(p.name, 'Someone') AS invited_by FROM public.tournament_team_invites tti JOIN public.tournament_teams tt ON tt.id = tti.tournament_team_id @@ -90,6 +99,7 @@ export class InvitesController { title: "Tournament Invite", body: `${NotificationsService.escapeHtml(invite.invited_by)} invited you to play for ${NotificationsService.escapeHtml(invite.team_name)} in ${NotificationsService.escapeHtml(invite.tournament_name)}.`, entityId: data.new.id, + icon: invite.tournament_logo, }); } @@ -100,6 +110,8 @@ export class InvitesController { title: string; body: string; entityId: string; + // Whose crest the push shows: the team's or the tournament's. + icon?: string | null; }, ) { try { @@ -109,6 +121,7 @@ export class InvitesController { role: "user", entity_id: invite.entityId, steamIds: [invite.steamId], + ...(invite.icon ? { data: { icon: invite.icon } } : {}), }); } catch (error) { // The invite itself is already written; losing its notification must not diff --git a/src/matches/clips/clips.service.ts b/src/matches/clips/clips.service.ts index e1ad0e11..8f4856b0 100644 --- a/src/matches/clips/clips.service.ts +++ b/src/matches/clips/clips.service.ts @@ -1541,12 +1541,22 @@ export class ClipsService { private async notifyClipReady(jobId: string) { try { const [job] = await this.postgres.query< - Array<{ user_steam_id: string; match_id: string | null }> + Array<{ + user_steam_id: string; + match_id: string | null; + thumbnail_clip_id: string | null; + }> >( `SELECT crj.user_steam_id::text AS user_steam_id, - mm.match_id::text AS match_id + mm.match_id::text AS match_id, + -- Only once the upload has landed a clip row with a poster + -- frame behind it; a render that reports before then simply + -- has no picture to show. + CASE WHEN mc.thumbnail_url IS NOT NULL THEN mc.id::text END + AS thumbnail_clip_id FROM public.clip_render_jobs crj LEFT JOIN public.match_maps mm ON mm.id = crj.match_map_id + LEFT JOIN public.match_clips mc ON mc.id = crj.clip_id WHERE crj.id = $1::uuid`, [jobId], ); @@ -1563,6 +1573,9 @@ export class ClipsService { role: "user", entity_id: jobId, steamIds: [job.user_steam_id], + ...(job.thumbnail_clip_id + ? { data: { image: `/clips/${job.thumbnail_clip_id}/thumbnail` } } + : {}), }); } catch (error) { this.logger.warn(`unable to notify of finished clip ${jobId}`, error); @@ -2834,7 +2847,10 @@ export class ClipsService { return raw === "true" || raw === "1"; } - private async readIntSetting(name: string, fallback: number): Promise { + private async readIntSetting( + name: string, + fallback: number, + ): Promise { const raw = await this.readSetting(name, String(fallback)); const n = parseInt(raw, 10); return Number.isFinite(n) ? n : fallback; diff --git a/src/matches/jobs/EventReminders.ts b/src/matches/jobs/EventReminders.ts index a5ad63dd..af069d63 100644 --- a/src/matches/jobs/EventReminders.ts +++ b/src/matches/jobs/EventReminders.ts @@ -10,6 +10,7 @@ type DueEvent = { name: string; label: string; window: string; + banner_filename: string | null; }; type EndedSeason = { @@ -28,7 +29,9 @@ export class EventReminders extends WorkerHost { } async process(): Promise { - return (await this.remindUpcomingEvents()) + (await this.announceEndedSeasons()); + return ( + (await this.remindUpcomingEvents()) + (await this.announceEndedSeasons()) + ); } // Same shape as TournamentReminders: two windows, each with a floor so an @@ -40,8 +43,14 @@ export class EventReminders extends WorkerHost { VALUES ('1w', 'starts in about a week', interval '7 days', interval '1 day'), ('1d', 'starts tomorrow', interval '1 day', interval '0') ) - SELECT e.id::text AS id, e.name, w.label, w.window_key AS window + SELECT e.id::text AS id, e.name, w.label, w.window_key AS window, + -- The banner as an image: a video banner has its poster frame, + -- and a linked (external) one has nothing to show. + COALESCE(m.thumbnail_filename, + CASE WHEN m.mime_type LIKE 'image/%' THEN m.filename END + ) AS banner_filename FROM public.events e + LEFT JOIN public.event_media m ON m.id = e.banner_media_id CROSS JOIN reminder_windows w WHERE e.starts_at IS NOT NULL AND e.starts_at > now() + w.floor_time @@ -76,6 +85,13 @@ export class EventReminders extends WorkerHost { role: "user", entity_id: `${event.id}:${event.window}`, steamIds: attendees.map((attendee) => attendee.steam_id), + ...(event.banner_filename + ? { + data: { + image: `/events/media/${event.id}/${event.banner_filename}`, + }, + } + : {}), }); sent++; diff --git a/src/matches/jobs/TournamentReminders.ts b/src/matches/jobs/TournamentReminders.ts index 659384a2..e03d93e6 100644 --- a/src/matches/jobs/TournamentReminders.ts +++ b/src/matches/jobs/TournamentReminders.ts @@ -9,6 +9,8 @@ type DueTournament = { id: string; name: string; start: string; + banner: string | null; + logo: string | null; label: string; window: string; }; @@ -35,7 +37,8 @@ export class TournamentReminders extends WorkerHost { VALUES ('1d', 'starts in about a day', interval '24 hours', interval '2 hours'), ('2h', 'starts in about 2 hours', interval '2 hours', interval '0') ) - SELECT t.id::text AS id, t.name, t."start", w.label, w.window_key AS window + SELECT t.id::text AS id, t.name, t."start", t.banner, t.logo, + w.label, w.window_key AS window FROM tournaments t CROSS JOIN reminder_windows w WHERE t.status IN ('RegistrationOpen', 'RegistrationClosed') @@ -50,9 +53,7 @@ export class TournamentReminders extends WorkerHost { let sent = 0; for (const tournament of due) { - const recipients = await this.postgres.query< - Array<{ steam_id: string }> - >( + const recipients = await this.postgres.query>( `SELECT DISTINCT steam_id::text AS steam_id FROM ( SELECT tt.owner_steam_id AS steam_id FROM tournament_teams tt @@ -79,6 +80,9 @@ export class TournamentReminders extends WorkerHost { role: "user", entity_id: `${tournament.id}:${tournament.window}`, steamIds: recipients.map((recipient) => recipient.steam_id), + ...((tournament.banner ?? tournament.logo) + ? { data: { image: tournament.banner ?? tournament.logo } } + : {}), }); sent++; diff --git a/src/news/news.service.ts b/src/news/news.service.ts index 410824ca..aedf6563 100644 --- a/src/news/news.service.ts +++ b/src/news/news.service.ts @@ -54,6 +54,9 @@ export class NewsService { article.title, )} was just published.`, entity_id: article.id, + ...(article.cover_image_url + ? { data: { image: article.cover_image_url } } + : {}), }); } diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 65e8e3e0..6721465d 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -148,7 +148,8 @@ export class NotificationsService { }); const name = players_by_pk?.name ?? `Player ${sanction.steamId}`; - const verb = NotificationsService.SANCTION_VERBS[sanction.type] ?? "sanctioned"; + const verb = + NotificationsService.SANCTION_VERBS[sanction.type] ?? "sanctioned"; const safeName = NotificationsService.escapeHtml(name); const profileUrl = `${this.appConfig.webDomain}/players/${encodeURIComponent( sanction.steamId, @@ -189,14 +190,21 @@ export class NotificationsService { return; } - const played = await this.postgres.query>( + // A brand-new registrant with a pre-existing VAC ban is exactly what admins + // want to hear about, so signing in counts as much as having played; only a + // steam id that never touched the platform (looked up via search) stays quiet. + const known = await this.postgres.query>( `SELECT EXISTS ( + SELECT 1 FROM public.players + WHERE steam_id = $1::bigint + AND last_sign_in_at IS NOT NULL + ) OR EXISTS ( SELECT 1 FROM public.match_lineup_players WHERE steam_id = $1::bigint ) AS exists`, [sanction.steamId], ); - if (!played.at(0)?.exists) { + if (!known.at(0)?.exists) { return; } @@ -369,7 +377,10 @@ export class NotificationsService { } catch (error) { // The per-row events are still queued behind this, so a failure here // degrades to the unbatched path rather than losing the push. - this.logger.warn("unable to batch push for a fan-out notification", error); + this.logger.warn( + "unable to batch push for a fan-out notification", + error, + ); } } @@ -471,9 +482,7 @@ export class NotificationsService { actions, in_app: inApp.has(steam_id), ...(notification.data ? { data: notification.data } : {}), - ...(notification.deletable === false - ? { deletable: false } - : {}), + ...(notification.deletable === false ? { deletable: false } : {}), })), }, returning: { @@ -587,6 +596,50 @@ export class NotificationsService { // players table, and the in-app preference is resolved inline for the same // reason. See notifyPlayers for why a player who muted the bell still gets a // row when they have somewhere to be pushed. + // The poster for a map, as a push notification image. Posters live in the + // web bundle (`/img/maps/screenshots/...`), not behind the API, so they are + // qualified here rather than by the push service's API-relative rule. + public async mapPosterImage( + by: { mapName: string | null } | { matchId: string }, + ): Promise { + try { + const [row] = + "matchId" in by + ? await this.postgres.query>( + `SELECT m.poster + FROM public.match_maps mm + JOIN public.maps m ON m.id = mm.map_id + WHERE mm.match_id = $1::uuid + ORDER BY mm."order" ASC + LIMIT 1`, + [by.matchId], + ) + : by.mapName + ? await this.postgres.query>( + `SELECT poster FROM public.maps WHERE name = $1 LIMIT 1`, + [by.mapName], + ) + : []; + + const poster = row?.poster; + + if (!poster) { + return undefined; + } + + if (/^https?:\/\//i.test(poster)) { + return poster; + } + + return poster.startsWith("/") && !poster.startsWith("//") + ? `${this.appConfig.webDomain}${poster}` + : undefined; + } catch (error) { + this.logger.warn("unable to resolve map poster for notification", error); + return undefined; + } + } + async notifyActivePlayers( type: e_notification_types_enum, notification: { diff --git a/src/notifications/push/push-notifications.service.spec.ts b/src/notifications/push/push-notifications.service.spec.ts index 390bdcd9..233a097b 100644 --- a/src/notifications/push/push-notifications.service.spec.ts +++ b/src/notifications/push/push-notifications.service.spec.ts @@ -1,5 +1,8 @@ import * as webPush from "web-push"; -import { PushNotificationsService } from "./push-notifications.service"; +import { + PushAction, + PushNotificationsService, +} from "./push-notifications.service"; // Asserted against rather than a string, because the generic queue processor // resolves the handler by exactly this name. import { SendPushDelivery } from "../jobs/SendPushDelivery"; @@ -89,7 +92,10 @@ describe("PushNotificationsService", () => { const withEnvKeys = () => configService.get.mockImplementation((key) => key === "app" - ? { webDomain: "https://example.com" } + ? { + webDomain: "https://example.com", + apiDomain: "https://api.example.com", + } : { publicKey: "public-key", privateKey: "private-key", @@ -106,6 +112,8 @@ describe("PushNotificationsService", () => { let quietSeconds: number; let bundled: Array>; let updates: Array<{ sql: string; bindings: any[] }>; + // What the badge-count query answers with. + let unread: number; // Keys are resolved from settings (with env taking precedence), so they are // not known until loadKeys() runs. @@ -149,8 +157,12 @@ describe("PushNotificationsService", () => { pipelined = []; updates = []; settings = {}; + unread = 4; postgres.query.mockImplementation(async (sql: string, bindings: any[]) => { + if (sql.includes("count(*)::text AS unread")) { + return [{ unread: String(unread) }]; + } if (sql.includes("FROM public.notifications\n")) { return notificationRow ? [notificationRow] : []; } @@ -316,7 +328,10 @@ describe("PushNotificationsService", () => { const withoutEnvKeys = () => configService.get.mockImplementation((key: string) => key === "app" - ? { webDomain: "https://example.com" } + ? { + webDomain: "https://example.com", + apiDomain: "https://api.example.com", + } : { subject: "https://example.com" }, ); @@ -752,6 +767,209 @@ describe("PushNotificationsService", () => { }); }); + describe("rich payload", () => { + const payloadOf = (call: number) => + JSON.parse((webPush.sendNotification as jest.Mock).mock.calls[call][1]); + + const send = async () => { + await service.sendForNotification({ + id: notificationRow.id, + type: notificationRow.type, + }); + expect(webPush.sendNotification).toHaveBeenCalledTimes(1); + return payloadOf(0); + }; + + it("qualifies API-served images and leaves full URLs alone", async () => { + // Stored avatar paths have no leading slash (avatars.service buildPath); + // writers that hand over a path of their own may well add one. + notificationRow = notification({ + data: { + icon: "https://avatars.steamstatic.com/abc_full.jpg", + image: "avatars/awards/gold.png", + }, + }); + + expect(await send()).toMatchObject({ + icon: "https://avatars.steamstatic.com/abc_full.jpg", + image: "https://api.example.com/avatars/awards/gold.png", + }); + + (webPush.sendNotification as jest.Mock).mockClear(); + notificationRow = notification({ data: { image: "/news/image/a.png" } }); + + expect(await send()).toMatchObject({ + image: "https://api.example.com/news/image/a.png", + }); + }); + + it("drops an image that is neither", async () => { + // `//evil.test/x` is a fully qualified URL, and the browser would fetch + // it as one. + notificationRow = notification({ data: { image: "//evil.test/x.png" } }); + + expect(await send()).not.toHaveProperty("image"); + }); + + it("tells the device how many the bell has waiting", async () => { + unread = 7; + + expect(await send()).toMatchObject({ + unread: 7, + graphqlUrl: "https://api.example.com/v1/graphql", + }); + }); + + it("still sends when the count cannot be taken", async () => { + const base = postgres.query.getMockImplementation(); + postgres.query.mockImplementation(async (sql: string, bindings: any[]) => + sql.includes("count(*)::text AS unread") + ? Promise.reject(new Error("db away")) + : base(sql, bindings), + ); + + const payload = await send(); + + expect(payload).not.toHaveProperty("unread"); + expect(payload.title).toBe("Match ready"); + }); + + it("offers Dismiss on a plain notification", async () => { + const payload = await send(); + + expect(payload.actions).toHaveLength(1); + expect(payload.actions[0]).toMatchObject({ + action: "dismiss", + title: "Dismiss", + }); + expect(payload.actions[0].operation.query).toContain( + "update_notifications(where:$v1,_set:$v2){affected_rows}", + ); + expect(payload.actions[0].operation.variables).toEqual({ + v1: { id: { _in: [notificationRow.id] } }, + v2: { is_read: true }, + }); + }); + + it("turns the bell's buttons into notification buttons", async () => { + notificationRow = notification({ + type: "ScrimRequestReceived", + entity_id: "req-1", + actions: [ + { + label: "Accept", + graphql: { + type: "mutation", + action: "respondToScrimRequest", + selection: { success: true }, + variables: { request_id: "req-1", accept: true }, + }, + }, + { + label: "Decline", + graphql: { + type: "mutation", + action: "respondToScrimRequest", + selection: { success: true }, + variables: { request_id: "req-1", accept: false }, + }, + }, + ], + }); + + const { actions } = await send(); + + expect(actions.map(({ title }: PushAction) => title)).toEqual([ + "Accept", + "Decline", + ]); + // The button runs the mutation, then reads the row -- the bell does the + // same two things when one of its buttons is pressed. + expect(actions[0].operation.query).toMatch( + /respondToScrimRequest\(request_id:\$v1,accept:\$v2\)\{success\},update_notifications\(/, + ); + expect(actions[0].operation.variables).toMatchObject({ + v1: "req-1", + v2: true, + }); + expect(actions[1].operation.variables).toMatchObject({ v2: false }); + }); + + it("lets a team invite be answered from the notification", async () => { + notificationRow = notification({ + type: "TeamInvite", + entity_id: "11111111-1111-1111-1111-111111111111", + message: "Ancients invited you", + }); + + const { actions } = await send(); + + expect(actions.map(({ action }: PushAction) => action)).toEqual([ + "accept", + "decline", + ]); + expect(actions[0].operation.query).toContain("acceptInvite("); + expect(actions[1].operation.query).toContain("denyInvite("); + expect(actions[0].operation.variables).toMatchObject({ + v1: "team", + v2: "11111111-1111-1111-1111-111111111111", + }); + }); + + it("lets a draft invite be answered from the notification", async () => { + notificationRow = notification({ + type: "DraftInvite", + entity_id: "22222222-2222-2222-2222-222222222222", + message: "Luke invited you to a draft", + }); + + const { actions } = await send(); + + expect(actions[0].operation.query).toContain("respondDraftInvite("); + expect(actions[0].operation.variables).toMatchObject({ + v1: "22222222-2222-2222-2222-222222222222", + v2: true, + }); + expect(actions[1].operation.variables).toMatchObject({ v2: false }); + }); + + it("gives chat no buttons", async () => { + // Reading the bell row would leave the conversation's own cursor where + // it was, so a Dismiss here would lie. + notificationRow = notification({ + type: "ChatMessage", + title: "Luke", + message: "hey", + entity_id: "match:m-1", + data: { threadKey: "chat:match:m-1", threadLabel: "Ancients vs Ratz" }, + }); + recipients = ["76561100000000001"]; + + expect((await send()).actions).toEqual([]); + }); + + it("keeps a broken stored action from blocking the push", async () => { + notificationRow = notification({ + actions: [ + { + label: "Boom", + graphql: { + type: "mutation", + action: "noSuchMutation", + selection: { success: true }, + variables: { x: 1 }, + }, + }, + ], + }); + + const payload = await send(); + + expect(payload.title).toBe("Match ready"); + expect(payload.actions).toEqual([]); + }); + }); + it("batches the fan-out types", () => { expect(PushNotificationsService.isBatched("NewsPublished")).toBe(true); expect(PushNotificationsService.isBatched("TournamentCreated")).toBe(true); diff --git a/src/notifications/push/push-notifications.service.ts b/src/notifications/push/push-notifications.service.ts index 57692124..9cc79367 100644 --- a/src/notifications/push/push-notifications.service.ts +++ b/src/notifications/push/push-notifications.service.ts @@ -10,6 +10,7 @@ import { RedisManagerService } from "../../redis/redis-manager/redis-manager.ser import { AppConfig } from "src/configs/types/AppConfig"; import { WebPushConfig } from "src/configs/types/WebPushConfig"; import { e_player_roles_enum } from "generated/schema"; +import { generateMutationOp } from "../../../generated"; import { rolesAtOrAbove } from "src/utilities/isRoleAbove"; import { SystemSettingName } from "src/system/enums/SystemSettingName"; import { pushCategoryForType } from "../preferences/notification-categories"; @@ -32,13 +33,27 @@ export type PushSubscriptionPayload = { }; }; +// Image paths are either absolute or API-served (`/avatars/...`, +// `/news/image/...`); a writer whose asset lives elsewhere sends the full URL. export type NotificationData = { threadKey?: string; threadLabel?: string; icon?: string; + image?: string; senderSteamId?: string; }; +// The bell's buttons, as written by NotificationsService.send / notifyPlayers. +export type NotificationAction = { + label: string; + graphql: { + type: string; + action: string; + selection: Record; + variables?: Record; + }; +}; + export type NotificationRow = { id: string; type: string; @@ -47,6 +62,16 @@ export type NotificationRow = { message: string; entity_id?: string | null; data?: NotificationData | null; + actions?: NotificationAction[] | null; +}; + +// A notification button as the service worker sees it: an id to match the +// click against and a ready-to-POST GraphQL operation, so the worker never +// has to know how to build one. +export type PushAction = { + action: string; + title: string; + operation: { query: string; variables?: Record }; }; type SubscriptionRow = { @@ -265,7 +290,6 @@ export class PushNotificationsService { this.publicKey = publicKey; } - // Which of these players could receive a push at all. Callers use it to // decide whether a recipient who muted the bell still needs a notifications // row written for them -- the INSERT event trigger is what delivers push, so @@ -573,7 +597,8 @@ export class PushNotificationsService { return; } - const policy = deliveryPolicyForType(newest.type) ?? DEFAULT_DELIVERY_POLICY; + const policy = + deliveryPolicyForType(newest.type) ?? DEFAULT_DELIVERY_POLICY; // Re-resolved rather than replayed: the whole point of holding these was // that the player might read them in the meantime, and between the window @@ -623,6 +648,7 @@ export class PushNotificationsService { // are only there to say whether it is still worth sending at all, and to // supply the text. await this.deliver( + delivery.steamId, delivery.subscriptions, delivery.notifications, ids.length, @@ -631,7 +657,7 @@ export class PushNotificationsService { } private static readonly SELECT_NOTIFICATION = `SELECT id::text AS id, type::text AS type, role::text AS role, - title, message, entity_id, data + title, message, entity_id, data, actions FROM public.notifications`; // The row a bundle is described by: its thread, its policy, and the link the @@ -683,7 +709,10 @@ export class PushNotificationsService { policy: DeliveryPolicy, representative: NotificationRow, ): Promise { - const [selectorSql, selectorParams]: [string, Array] = + const [selectorSql, selectorParams]: [ + string, + Array, + ] = "ids" in selector ? [`n.id = ANY($1::uuid[])`, [selector.ids]] : [ @@ -707,7 +736,7 @@ export class PushNotificationsService { const rows = await this.postgres.query( `SELECT n.id::text AS id, n.type::text AS type, n.role::text AS role, - n.title, n.message, n.entity_id, n.data, + n.title, n.message, n.entity_id, n.data, n.actions, p.steam_id::text AS steam_id, public.quiet_hours_seconds_remaining( p.quiet_hours_start, p.quiet_hours_end, p.notification_timezone @@ -768,10 +797,13 @@ export class PushNotificationsService { message: row.message, entity_id: row.entity_id, data: row.data, + actions: row.actions, }); } - if (!delivery.subscriptions.some(({ id }) => id === row.subscription_id)) { + if ( + !delivery.subscriptions.some(({ id }) => id === row.subscription_id) + ) { delivery.subscriptions.push({ id: row.subscription_id, endpoint: row.endpoint, @@ -837,7 +869,11 @@ export class PushNotificationsService { } if (policy.bundleSeconds === 0) { - await this.deliver(delivery.subscriptions, delivery.notifications); + await this.deliver( + delivery.steamId, + delivery.subscriptions, + delivery.notifications, + ); continue; } @@ -848,7 +884,11 @@ export class PushNotificationsService { ); if (claim.leading) { - await this.deliver(delivery.subscriptions, delivery.notifications); + await this.deliver( + delivery.steamId, + delivery.subscriptions, + delivery.notifications, + ); // Held as well as sent. The summary replaces this notification on the // device, so leaving it out would make a burst of four report three. @@ -1064,7 +1104,178 @@ export class PushNotificationsService { }; } + // Same rule as web/utilities/avatarUrl.ts: a stored `avatars/...` path (with + // or without its leading slash) is served by the API; anything with a scheme + // is left alone. The browser fetches a notification image from the push + // service's context, not the page's, so it has to be a full URL. + private assetUrl(path: string | undefined | null): string | undefined { + if (!path) { + return undefined; + } + + if (/^https?:\/\//i.test(path)) { + return path; + } + + // `//evil.test/x` is a fully qualified URL to somewhere else; any other + // scheme the browser would refuse anyway. + if (path.startsWith("//") || /^[a-z][a-z\d+\-.]*:/i.test(path)) { + return undefined; + } + + return `${this.appConfig.apiDomain}/${path.replace(/^\/+/, "")}`; + } + + private static markReadSelection(ids: string[]) { + return { + update_notifications: { + __args: { + where: { id: { _in: ids } }, + _set: { is_read: true }, + }, + affected_rows: true, + }, + }; + } + + // One button: the mutation the bell would run for it, followed by marking + // the rows read -- which is what the bell does after any of its buttons + // (AppNotifications.vue handleAction), so the push stays in step. + private static pushAction( + action: string, + title: string, + mutation: Record, + ids: string[], + ): PushAction { + return { + action, + title, + operation: generateMutationOp({ + ...mutation, + ...PushNotificationsService.markReadSelection(ids), + } as Parameters[0]), + }; + } + + // What the device can offer besides opening the app. Every operation runs + // from the service worker with the player's own cookie, so it is exactly as + // privileged as the same button in the bell. + private static pushActionsFor( + notifications: NotificationRow[], + count: number, + ): PushAction[] { + const newest = notifications.at(-1); + const ids = notifications.map(({ id }) => id); + + // Marking a bell row read says nothing about the conversation's own read + // cursor, and a "Dismiss" that leaves the thread unread would mislead. + if (newest.type.endsWith("ChatMessage")) { + return []; + } + + const dismiss = [ + PushNotificationsService.pushAction("dismiss", "Dismiss", {}, ids), + ]; + + // A bundle describes several things at once; the only honest button is + // the one that applies to all of them. + if (count > 1 || notifications.length > 1) { + return dismiss; + } + + if (newest.actions?.length) { + return newest.actions.map(({ label, graphql }, index) => + PushNotificationsService.pushAction( + String(index), + label, + { + [graphql.action]: { + __args: graphql.variables ?? {}, + ...graphql.selection, + }, + }, + ids, + ), + ); + } + + // The invites are answered from the bell by components of their own rather + // than through `actions` (ActionToasts.vue, DraftInviteNotification.vue), + // so their buttons are spelled out here with the same mutations. + const inviteType = + newest.type === "TeamInvite" + ? "team" + : newest.type === "TournamentTeamInvite" + ? "tournament" + : null; + + if (inviteType && newest.entity_id) { + const variables = { type: inviteType, invite_id: newest.entity_id }; + + return [ + PushNotificationsService.pushAction( + "accept", + "Accept", + { acceptInvite: { __args: variables, success: true } }, + ids, + ), + PushNotificationsService.pushAction( + "decline", + "Decline", + { denyInvite: { __args: variables, success: true } }, + ids, + ), + ]; + } + + if (newest.type === "DraftInvite" && newest.entity_id) { + return [true, false].map((accept) => + PushNotificationsService.pushAction( + accept ? "accept" : "decline", + accept ? "Accept" : "Decline", + { + respondDraftInvite: { + __args: { draftGameId: newest.entity_id, accept }, + success: true, + }, + }, + ids, + ), + ); + } + + return dismiss; + } + + // What the app icon should say once this push lands. Only rows the bell + // would show this player; the bell's own number also counts invites and the + // like, and the page re-syncs the badge from that the moment it is open. + private async unreadCount(steamId: string): Promise { + try { + const rows = await this.postgres.query>( + `SELECT count(*)::text AS unread + FROM public.notifications + WHERE steam_id = $1 + AND in_app = true + AND is_read = false + AND deleted_at IS NULL`, + [steamId], + ); + + const unread = Number(rows.at(0)?.unread); + + return Number.isInteger(unread) ? unread : undefined; + } catch (error) { + this.logger.warn( + `unable to count unread notifications for ${steamId}`, + error, + ); + return undefined; + } + } + private async deliver( + steamId: string, subscriptions: SubscriptionRow[], notifications: NotificationRow[], // How many arrived, which is not always how many rows are left to describe @@ -1086,11 +1297,22 @@ export class PushNotificationsService { } : PushNotificationsService.summarize(notifications, count); + // A row whose stored action no longer matches the schema must not take + // the notification down with it; it just loses its buttons. + let actions: PushAction[] = []; + + try { + actions = PushNotificationsService.pushActionsFor(notifications, count); + } catch (error) { + this.logger.warn(`unable to build push actions for ${newest.id}`, error); + } + const payload = JSON.stringify({ title, body, url: notificationUrl(newest, this.appConfig.webDomain), - icon: newest.data?.icon, + icon: this.assetUrl(newest.data?.icon), + image: this.assetUrl(newest.data?.image), // Lets a device collapse repeats of the same conversation or match // rather than stacking a separate notification for each. tag: thread, @@ -1100,6 +1322,9 @@ export class PushNotificationsService { renotify: true, threadKey: thread, count, + unread: await this.unreadCount(steamId), + actions, + graphqlUrl: `${this.appConfig.apiDomain}/v1/graphql`, }); const delivered: string[] = []; diff --git a/src/steam-presence/steam-presence.service.ts b/src/steam-presence/steam-presence.service.ts index 980399f1..9a6beb7b 100644 --- a/src/steam-presence/steam-presence.service.ts +++ b/src/steam-presence/steam-presence.service.ts @@ -277,7 +277,9 @@ export class SteamPresenceService // ---- per-account steam client ------------------------------------------ private connectAccount(account: FriendsAccount): void { - this.logger.log(`steam-presence connecting bot account ${account.username}`); + this.logger.log( + `steam-presence connecting bot account ${account.username}`, + ); const client = new SteamUser({ enablePicsCache: false, autoRelogin: true, @@ -323,7 +325,12 @@ export class SteamPresenceService this.pendingGuards.delete(account.id); void this.redis.del(GUARD_PREFIX + account.id).catch(() => {}); void this.redis - .set(ONLINE_PREFIX + account.id, this.instanceId, "EX", LOCK_TTL_SECONDS) + .set( + ONLINE_PREFIX + account.id, + this.instanceId, + "EX", + LOCK_TTL_SECONDS, + ) .catch(() => {}); if (steamId) { void this.postgres @@ -351,7 +358,10 @@ export class SteamPresenceService LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_BASE_MS * 2 ** (attempts - 1), ); - this.loginBackoff.set(account.id, { until: Date.now() + delay, attempts }); + this.loginBackoff.set(account.id, { + until: Date.now() + delay, + attempts, + }); this.logger.warn( `steam-presence ${account.username} reconnect backoff ${Math.round(delay / 1000)}s (attempt ${attempts})`, ); @@ -420,8 +430,13 @@ export class SteamPresenceService void this.logOn(client, account); } - private async logOn(client: SteamUser, account: FriendsAccount): Promise { - const refreshToken = await this.cache.get(REFRESH_TOKEN_PREFIX + account.id); + private async logOn( + client: SteamUser, + account: FriendsAccount, + ): Promise { + const refreshToken = await this.cache.get( + REFRESH_TOKEN_PREFIX + account.id, + ); if (this.clients.get(account.id) !== client) { return; } @@ -457,12 +472,7 @@ export class SteamPresenceService if (releaseLock) { this.owned.delete(accountId); await this.redis - ?.eval( - RELEASE_LUA, - 1, - ACCOUNT_LOCK_PREFIX + accountId, - this.instanceId, - ) + ?.eval(RELEASE_LUA, 1, ACCOUNT_LOCK_PREFIX + accountId, this.instanceId) .catch(() => {}); } } @@ -539,7 +549,9 @@ export class SteamPresenceService steamId: string, input: { gameid?: string | number | null; - richPresence: Record | Array<{ key?: string; value?: string }>; + richPresence: + | Record + | Array<{ key?: string; value?: string }>; display?: string | null; }, ): Promise { @@ -550,7 +562,9 @@ export class SteamPresenceService }); const stateKey = STATE_PREFIX + steamId; - const previous = (await this.cache.get(stateKey)) as Cs2PresenceState | null; + const previous = (await this.cache.get( + stateKey, + )) as Cs2PresenceState | null; // Skip no-op writes: the push `user` event fires often, but we only keep the // latest state, so writing unchanged state just churns Postgres dead tuples. @@ -707,6 +721,9 @@ export class SteamPresenceService return; } const byPlayer = new Map(notice.players.map((p) => [p.steamId, p])); + const image = await this.notifications.mapPosterImage({ + mapName: notice.mapName, + }); for (const friend of friends) { const stats = byPlayer.get(friend.steam_id); @@ -729,6 +746,7 @@ export class SteamPresenceService role: "user", entity_id: notice.matchId, steamIds: [friend.steam_id], + ...(image ? { data: { image } } : {}), }) .catch((err) => this.logger.warn( @@ -881,10 +899,7 @@ export class SteamPresenceService bots: botRows.length, online: botRows.filter((b) => b.online).length, watching: botRows.reduce((sum, b) => sum + b.watching, 0), - pending: botRows.reduce( - (sum, b) => sum + (b.assigned - b.watching), - 0, - ), + pending: botRows.reduce((sum, b) => sum + (b.assigned - b.watching), 0), capacity: botRows.reduce((sum, b) => sum + b.capacity, 0), }, bots: botRows, diff --git a/src/tournaments/tournaments.controller.ts b/src/tournaments/tournaments.controller.ts index 8c45020e..40a8c54d 100644 --- a/src/tournaments/tournaments.controller.ts +++ b/src/tournaments/tournaments.controller.ts @@ -42,10 +42,16 @@ export class TournamentsController { (tournament.name as string) ?? "A tournament", ); + const image = (tournament.banner ?? tournament.logo) as + | string + | null + | undefined; + await this.notifications.notifyActivePlayers("TournamentCreated", { title: "New tournament", message: `${name} is open for signups.`, entity_id: tournamentId, + ...(image ? { data: { image } } : {}), }); } catch (error) { this.logger.warn( From 7afbdfd80ab5b4e6791cf0c1606e4e60fc48d1ff Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 10:42:58 -0400 Subject: [PATCH 03/10] wip --- hasura/triggers/game_modes.sql | 9 ++--- src/telemetry/types/TelemetryPayload.ts | 4 +- test/game-modes.spec.ts | 52 ++++++++++++++----------- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/hasura/triggers/game_modes.sql b/hasura/triggers/game_modes.sql index e19d6e75..bcd2ddcc 100644 --- a/hasura/triggers/game_modes.sql +++ b/hasura/triggers/game_modes.sql @@ -3,15 +3,14 @@ CREATE OR REPLACE FUNCTION public.match_ranking_for_options(_match_options_id uu LANGUAGE sql STABLE AS $$ - -- No mode is ranked. A mode counts only when it is explicitly marked safe - -- for competitive play, so a newly created mode can never quietly start - -- affecting ELO. + -- Only a plain competitive match counts. Any custom mode plays under + -- third-party plugins, so it never moves ELO -- unconditionally, whatever + -- the mode's flags say. competitive_safe gates draft-lobby selection only. SELECT NOT EXISTS ( SELECT 1 FROM match_options mo - INNER JOIN game_modes gm ON gm.id = mo.game_mode_id WHERE mo.id = _match_options_id - AND gm.competitive_safe = false + AND mo.game_mode_id IS NOT NULL ); $$; diff --git a/src/telemetry/types/TelemetryPayload.ts b/src/telemetry/types/TelemetryPayload.ts index cf2baaf6..1eb42041 100644 --- a/src/telemetry/types/TelemetryPayload.ts +++ b/src/telemetry/types/TelemetryPayload.ts @@ -104,8 +104,8 @@ export type TelemetryPlugins = { manual: number; modes: number; modes_enabled: number; - // Modes that are not competitive_safe, i.e. matches that deliberately do not - // count toward ranking. + // Modes that are not competitive_safe, i.e. not offered in draft lobbies. + // (Every custom mode is unranked regardless; the flag only gates drafts.) modes_unranked: number; }; diff --git a/test/game-modes.spec.ts b/test/game-modes.spec.ts index 625ab5ac..573edb4d 100644 --- a/test/game-modes.spec.ts +++ b/test/game-modes.spec.ts @@ -368,9 +368,10 @@ describe("game modes (SQL-driven)", () => { }); }); -// A mode that is not competitive_safe still plays a real match on a real -// server: stats, demos and rounds are all recorded. It simply does not move -// anybody's rating, and it is kept off the stats leaderboards. +// A match under any custom mode still plays for real on a real server: stats, +// demos and rounds are all recorded. It simply moves nobody's rating and stays +// off the stats leaderboards -- only a plain competitive match counts, and +// competitive_safe gates draft-lobby selection rather than ranking. describe("unranked game modes (SQL-driven)", () => { let db: SqlTestDb; let postgres: PostgresService; @@ -463,14 +464,14 @@ describe("unranked game modes (SQL-driven)", () => { expect(row.counts_toward_ranking).toBe(false); }); - it("still counts a match under a competitive-safe mode", async () => { + it("marks a match under a draft-eligible mode as not counting either", async () => { const match = await playedMatch(await mode(true)); const [row] = await postgres.query< Array<{ counts_toward_ranking: boolean }> >("SELECT counts_toward_ranking FROM matches WHERE id = $1", [match.id]); - expect(row.counts_toward_ranking).toBe(true); + expect(row.counts_toward_ranking).toBe(false); }); it("counts a match with no mode at all", async () => { @@ -490,31 +491,35 @@ describe("unranked game modes (SQL-driven)", () => { expect(await eloRowCount(match.id)).toEqual(0); }); - it("writes ELO for the same match under a safe mode", async () => { - const match = await playedMatch(await mode(true)); + it("writes ELO for the same match with no mode", async () => { + const match = await playedMatch(); expect(await generate(match.id)).toBeGreaterThan(0); expect(await eloRowCount(match.id)).toBeGreaterThan(0); }); - it("follows the mode when it is swapped before the match is played", async () => { + it("follows the mode when it is cleared before the match is played", async () => { const optionsId = await fx.matchOptions({ type: "Duel", gameModeId: await mode(true), }); const match = await fx.match(optionsId); - const funMode = await mode(false); + + const before = await postgres.query< + Array<{ counts_toward_ranking: boolean }> + >("SELECT counts_toward_ranking FROM matches WHERE id = $1", [match.id]); + expect(before[0].counts_toward_ranking).toBe(false); await postgres.query( - "UPDATE match_options SET game_mode_id = $1 WHERE id = $2", - [funMode, optionsId], + "UPDATE match_options SET game_mode_id = NULL WHERE id = $1", + [optionsId], ); const [row] = await postgres.query< Array<{ counts_toward_ranking: boolean }> >("SELECT counts_toward_ranking FROM matches WHERE id = $1", [match.id]); - expect(row.counts_toward_ranking).toBe(false); + expect(row.counts_toward_ranking).toBe(true); }); // The decision is stored, not derived, so history cannot be rewritten by @@ -529,7 +534,7 @@ describe("unranked game modes (SQL-driven)", () => { const alice = await fx.player(); const bob = await fx.player(); - const play = async (gameModeId: string) => { + const play = async (gameModeId?: string) => { const optionsId = await fx.matchOptions({ type: "Duel", gameModeId }); const match = await fx.match(optionsId); await fx.lineupPlayer(match.lineup_1_id, alice); @@ -556,16 +561,19 @@ describe("unranked game modes (SQL-driven)", () => { return row ? Number(row.current) : null; }; - await play(safe); + await play(); const afterRanked = await ratingOf(alice); expect(afterRanked).not.toBeNull(); + // Neither flavor of mode moves the rating -- draft-eligible or not. await play(unsafe); expect(await ratingOf(alice)).toEqual(afterRanked); + await play(safe); + expect(await ratingOf(alice)).toEqual(afterRanked); - // And the engine still works afterwards -- the unranked match in between + // And the engine still works afterwards -- the unranked matches in between // did not leave the chain in a state the next one refuses to build on. - await play(safe); + await play(); expect(await ratingOf(alice)).not.toEqual(afterRanked); }); @@ -595,8 +603,8 @@ describe("unranked game modes (SQL-driven)", () => { }); it("keeps an unranked match off the leaderboard", async () => { - const build = async (competitiveSafe: boolean) => { - const match = await playedMatch(await mode(competitiveSafe)); + const build = async (gameModeId?: string) => { + const match = await playedMatch(gameModeId); const [map] = await postgres.query>( `INSERT INTO match_maps (match_id, map_id, "order") SELECT $1, id, 1 FROM maps ORDER BY name LIMIT 1 RETURNING id`, @@ -607,7 +615,7 @@ describe("unranked game modes (SQL-driven)", () => { return attacker; }; - const unrankedPlayer = await build(false); + const unrankedPlayer = await build(await mode(true)); const onBoard = async () => { const rows = await postgres.query>( @@ -618,9 +626,9 @@ describe("unranked game modes (SQL-driven)", () => { expect(await onBoard()).not.toContain(String(unrankedPlayer)); - // The same shape under a safe mode does appear, so the assertion above is + // The same shape with no mode does appear, so the assertion above is // about the ranking flag and not about the fixture being incomplete. - const rankedPlayer = await build(true); + const rankedPlayer = await build(); expect(await onBoard()).toContain(String(rankedPlayer)); }); @@ -675,7 +683,7 @@ describe("starter game modes (SQL-driven)", () => { expect(modes.map((mode) => mode.slug)).toEqual(["deathmatch", "retakes"]); }); - it("marks them unranked, so they never quietly move ELO", async () => { + it("keeps them out of draft lobbies by default", async () => { const modes = await seeded(); expect(modes.every((mode) => mode.competitive_safe === false)).toBe(true); }); From a29977e3d3aeae6ed3915523e3dd46dfdc4ed964 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 14:29:25 -0400 Subject: [PATCH 04/10] wip --- generated/schema.graphql | 16 ++ generated/schema.ts | 26 ++- generated/types.ts | 30 ++++ hasura/enums/maps.sql | 3 +- .../databases/default/tables/public_maps.yaml | 7 +- .../1879000000000_maps_soft_delete/down.sql | 1 + .../1879000000000_maps_soft_delete/up.sql | 1 + hasura/triggers/maps.sql | 19 +++ hasura/triggers/v_pool_maps.sql | 4 + src/discord-bot/discord-bot.service.ts | 3 + src/discord-bot/interactions/ScheduleMatch.ts | 3 + test/maps-soft-delete.spec.ts | 160 ++++++++++++++++++ 12 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 hasura/migrations/default/1879000000000_maps_soft_delete/down.sql create mode 100644 hasura/migrations/default/1879000000000_maps_soft_delete/up.sql create mode 100644 hasura/triggers/maps.sql create mode 100644 test/maps-soft-delete.spec.ts diff --git a/generated/schema.graphql b/generated/schema.graphql index 7a2d1370..fe3e6953 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -30922,6 +30922,7 @@ columns and relationships of "maps" """ type maps { active_pool: Boolean! + deleted_at: timestamptz """An object relationship""" e_match_type: e_match_types! @@ -31078,6 +31079,7 @@ input maps_bool_exp { _not: maps_bool_exp _or: [maps_bool_exp!] active_pool: Boolean_comparison_exp + deleted_at: timestamptz_comparison_exp e_match_type: e_match_types_bool_exp enabled: Boolean_comparison_exp id: uuid_comparison_exp @@ -31113,6 +31115,7 @@ input type for inserting data into table "maps" """ input maps_insert_input { active_pool: Boolean + deleted_at: timestamptz e_match_type: e_match_types_obj_rel_insert_input enabled: Boolean id: uuid @@ -31128,6 +31131,7 @@ input maps_insert_input { """aggregate max on columns""" type maps_max_fields { + deleted_at: timestamptz id: uuid label: String name: String @@ -31140,6 +31144,7 @@ type maps_max_fields { order by max() on columns of table "maps" """ input maps_max_order_by { + deleted_at: order_by id: order_by label: order_by name: order_by @@ -31150,6 +31155,7 @@ input maps_max_order_by { """aggregate min on columns""" type maps_min_fields { + deleted_at: timestamptz id: uuid label: String name: String @@ -31162,6 +31168,7 @@ type maps_min_fields { order by min() on columns of table "maps" """ input maps_min_order_by { + deleted_at: order_by id: order_by label: order_by name: order_by @@ -31203,6 +31210,7 @@ input maps_on_conflict { """Ordering options when selecting data from "maps".""" input maps_order_by { active_pool: order_by + deleted_at: order_by e_match_type: e_match_types_order_by enabled: order_by id: order_by @@ -31228,6 +31236,9 @@ enum maps_select_column { """column name""" active_pool + """column name""" + deleted_at + """column name""" enabled @@ -31280,6 +31291,7 @@ input type for updating data in table "maps" """ input maps_set_input { active_pool: Boolean + deleted_at: timestamptz enabled: Boolean id: uuid label: String @@ -31304,6 +31316,7 @@ input maps_stream_cursor_input { """Initial value of the column from where the streaming should start""" input maps_stream_cursor_value_input { active_pool: Boolean + deleted_at: timestamptz enabled: Boolean id: uuid label: String @@ -31321,6 +31334,9 @@ enum maps_update_column { """column name""" active_pool + """column name""" + deleted_at + """column name""" enabled diff --git a/generated/schema.ts b/generated/schema.ts index f72e7c19..2c5f2b3f 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -11003,6 +11003,7 @@ export type map_pools_update_column = 'enabled' | 'id' | 'seed' | 'type' /** columns and relationships of "maps" */ export interface maps { active_pool: Scalars['Boolean'] + deleted_at: (Scalars['timestamptz'] | null) /** An object relationship */ e_match_type: e_match_types enabled: Scalars['Boolean'] @@ -11048,6 +11049,7 @@ export type maps_constraint = 'maps_name_type_key' | 'maps_pkey' /** aggregate max on columns */ export interface maps_max_fields { + deleted_at: (Scalars['timestamptz'] | null) id: (Scalars['uuid'] | null) label: (Scalars['String'] | null) name: (Scalars['String'] | null) @@ -11060,6 +11062,7 @@ export interface maps_max_fields { /** aggregate min on columns */ export interface maps_min_fields { + deleted_at: (Scalars['timestamptz'] | null) id: (Scalars['uuid'] | null) label: (Scalars['String'] | null) name: (Scalars['String'] | null) @@ -11081,7 +11084,7 @@ export interface maps_mutation_response { /** select columns of table "maps" */ -export type maps_select_column = 'active_pool' | 'enabled' | 'id' | 'label' | 'name' | 'patch' | 'poster' | 'type' | 'workshop_map_id' +export type maps_select_column = 'active_pool' | 'deleted_at' | 'enabled' | 'id' | 'label' | 'name' | 'patch' | 'poster' | 'type' | 'workshop_map_id' /** select "maps_aggregate_bool_exp_bool_and_arguments_columns" columns of table "maps" */ @@ -11093,7 +11096,7 @@ export type maps_select_column_maps_aggregate_bool_exp_bool_or_arguments_columns /** update columns of table "maps" */ -export type maps_update_column = 'active_pool' | 'enabled' | 'id' | 'label' | 'name' | 'patch' | 'poster' | 'type' | 'workshop_map_id' +export type maps_update_column = 'active_pool' | 'deleted_at' | 'enabled' | 'id' | 'label' | 'name' | 'patch' | 'poster' | 'type' | 'workshop_map_id' /** columns and relationships of "match_clips" */ @@ -55983,6 +55986,7 @@ where: map_pools_bool_exp} /** columns and relationships of "maps" */ export interface mapsGenqlSelection{ active_pool?: boolean | number + deleted_at?: boolean | number /** An object relationship */ e_match_type?: e_match_typesGenqlSelection enabled?: boolean | number @@ -56084,15 +56088,16 @@ on_conflict?: (maps_on_conflict | null)} /** Boolean expression to filter rows from the table "maps". All fields are combined with a logical 'AND'. */ -export interface maps_bool_exp {_and?: (maps_bool_exp[] | null),_not?: (maps_bool_exp | null),_or?: (maps_bool_exp[] | null),active_pool?: (Boolean_comparison_exp | null),e_match_type?: (e_match_types_bool_exp | null),enabled?: (Boolean_comparison_exp | null),id?: (uuid_comparison_exp | null),label?: (String_comparison_exp | null),match_maps?: (match_maps_bool_exp | null),match_maps_aggregate?: (match_maps_aggregate_bool_exp | null),match_veto_picks?: (match_map_veto_picks_bool_exp | null),match_veto_picks_aggregate?: (match_map_veto_picks_aggregate_bool_exp | null),name?: (String_comparison_exp | null),patch?: (String_comparison_exp | null),poster?: (String_comparison_exp | null),type?: (e_match_types_enum_comparison_exp | null),workshop_map_id?: (String_comparison_exp | null)} +export interface maps_bool_exp {_and?: (maps_bool_exp[] | null),_not?: (maps_bool_exp | null),_or?: (maps_bool_exp[] | null),active_pool?: (Boolean_comparison_exp | null),deleted_at?: (timestamptz_comparison_exp | null),e_match_type?: (e_match_types_bool_exp | null),enabled?: (Boolean_comparison_exp | null),id?: (uuid_comparison_exp | null),label?: (String_comparison_exp | null),match_maps?: (match_maps_bool_exp | null),match_maps_aggregate?: (match_maps_aggregate_bool_exp | null),match_veto_picks?: (match_map_veto_picks_bool_exp | null),match_veto_picks_aggregate?: (match_map_veto_picks_aggregate_bool_exp | null),name?: (String_comparison_exp | null),patch?: (String_comparison_exp | null),poster?: (String_comparison_exp | null),type?: (e_match_types_enum_comparison_exp | null),workshop_map_id?: (String_comparison_exp | null)} /** input type for inserting data into table "maps" */ -export interface maps_insert_input {active_pool?: (Scalars['Boolean'] | null),e_match_type?: (e_match_types_obj_rel_insert_input | null),enabled?: (Scalars['Boolean'] | null),id?: (Scalars['uuid'] | null),label?: (Scalars['String'] | null),match_maps?: (match_maps_arr_rel_insert_input | null),match_veto_picks?: (match_map_veto_picks_arr_rel_insert_input | null),name?: (Scalars['String'] | null),patch?: (Scalars['String'] | null),poster?: (Scalars['String'] | null),type?: (e_match_types_enum | null),workshop_map_id?: (Scalars['String'] | null)} +export interface maps_insert_input {active_pool?: (Scalars['Boolean'] | null),deleted_at?: (Scalars['timestamptz'] | null),e_match_type?: (e_match_types_obj_rel_insert_input | null),enabled?: (Scalars['Boolean'] | null),id?: (Scalars['uuid'] | null),label?: (Scalars['String'] | null),match_maps?: (match_maps_arr_rel_insert_input | null),match_veto_picks?: (match_map_veto_picks_arr_rel_insert_input | null),name?: (Scalars['String'] | null),patch?: (Scalars['String'] | null),poster?: (Scalars['String'] | null),type?: (e_match_types_enum | null),workshop_map_id?: (Scalars['String'] | null)} /** aggregate max on columns */ export interface maps_max_fieldsGenqlSelection{ + deleted_at?: boolean | number id?: boolean | number label?: boolean | number name?: boolean | number @@ -56105,11 +56110,12 @@ export interface maps_max_fieldsGenqlSelection{ /** order by max() on columns of table "maps" */ -export interface maps_max_order_by {id?: (order_by | null),label?: (order_by | null),name?: (order_by | null),patch?: (order_by | null),poster?: (order_by | null),workshop_map_id?: (order_by | null)} +export interface maps_max_order_by {deleted_at?: (order_by | null),id?: (order_by | null),label?: (order_by | null),name?: (order_by | null),patch?: (order_by | null),poster?: (order_by | null),workshop_map_id?: (order_by | null)} /** aggregate min on columns */ export interface maps_min_fieldsGenqlSelection{ + deleted_at?: boolean | number id?: boolean | number label?: boolean | number name?: boolean | number @@ -56122,7 +56128,7 @@ export interface maps_min_fieldsGenqlSelection{ /** order by min() on columns of table "maps" */ -export interface maps_min_order_by {id?: (order_by | null),label?: (order_by | null),name?: (order_by | null),patch?: (order_by | null),poster?: (order_by | null),workshop_map_id?: (order_by | null)} +export interface maps_min_order_by {deleted_at?: (order_by | null),id?: (order_by | null),label?: (order_by | null),name?: (order_by | null),patch?: (order_by | null),poster?: (order_by | null),workshop_map_id?: (order_by | null)} /** response of any mutation on the table "maps" */ @@ -56147,7 +56153,7 @@ export interface maps_on_conflict {constraint: maps_constraint,update_columns?: /** Ordering options when selecting data from "maps". */ -export interface maps_order_by {active_pool?: (order_by | null),e_match_type?: (e_match_types_order_by | null),enabled?: (order_by | null),id?: (order_by | null),label?: (order_by | null),match_maps_aggregate?: (match_maps_aggregate_order_by | null),match_veto_picks_aggregate?: (match_map_veto_picks_aggregate_order_by | null),name?: (order_by | null),patch?: (order_by | null),poster?: (order_by | null),type?: (order_by | null),workshop_map_id?: (order_by | null)} +export interface maps_order_by {active_pool?: (order_by | null),deleted_at?: (order_by | null),e_match_type?: (e_match_types_order_by | null),enabled?: (order_by | null),id?: (order_by | null),label?: (order_by | null),match_maps_aggregate?: (match_maps_aggregate_order_by | null),match_veto_picks_aggregate?: (match_map_veto_picks_aggregate_order_by | null),name?: (order_by | null),patch?: (order_by | null),poster?: (order_by | null),type?: (order_by | null),workshop_map_id?: (order_by | null)} /** primary key columns input for table: maps */ @@ -56155,7 +56161,7 @@ export interface maps_pk_columns_input {id: Scalars['uuid']} /** input type for updating data in table "maps" */ -export interface maps_set_input {active_pool?: (Scalars['Boolean'] | null),enabled?: (Scalars['Boolean'] | null),id?: (Scalars['uuid'] | null),label?: (Scalars['String'] | null),name?: (Scalars['String'] | null),patch?: (Scalars['String'] | null),poster?: (Scalars['String'] | null),type?: (e_match_types_enum | null),workshop_map_id?: (Scalars['String'] | null)} +export interface maps_set_input {active_pool?: (Scalars['Boolean'] | null),deleted_at?: (Scalars['timestamptz'] | null),enabled?: (Scalars['Boolean'] | null),id?: (Scalars['uuid'] | null),label?: (Scalars['String'] | null),name?: (Scalars['String'] | null),patch?: (Scalars['String'] | null),poster?: (Scalars['String'] | null),type?: (e_match_types_enum | null),workshop_map_id?: (Scalars['String'] | null)} /** Streaming cursor of the table "maps" */ @@ -56167,7 +56173,7 @@ ordering?: (cursor_ordering | null)} /** Initial value of the column from where the streaming should start */ -export interface maps_stream_cursor_value_input {active_pool?: (Scalars['Boolean'] | null),enabled?: (Scalars['Boolean'] | null),id?: (Scalars['uuid'] | null),label?: (Scalars['String'] | null),name?: (Scalars['String'] | null),patch?: (Scalars['String'] | null),poster?: (Scalars['String'] | null),type?: (e_match_types_enum | null),workshop_map_id?: (Scalars['String'] | null)} +export interface maps_stream_cursor_value_input {active_pool?: (Scalars['Boolean'] | null),deleted_at?: (Scalars['timestamptz'] | null),enabled?: (Scalars['Boolean'] | null),id?: (Scalars['uuid'] | null),label?: (Scalars['String'] | null),name?: (Scalars['String'] | null),patch?: (Scalars['String'] | null),poster?: (Scalars['String'] | null),type?: (e_match_types_enum | null),workshop_map_id?: (Scalars['String'] | null)} export interface maps_updates { /** sets the columns of the filtered rows to the given values */ @@ -128285,6 +128291,7 @@ export const enumMapsConstraint = { export const enumMapsSelectColumn = { active_pool: 'active_pool' as const, + deleted_at: 'deleted_at' as const, enabled: 'enabled' as const, id: 'id' as const, label: 'label' as const, @@ -128307,6 +128314,7 @@ export const enumMapsSelectColumnMapsAggregateBoolExpBoolOrArgumentsColumns = { export const enumMapsUpdateColumn = { active_pool: 'active_pool' as const, + deleted_at: 'deleted_at' as const, enabled: 'enabled' as const, id: 'id' as const, label: 'label' as const, diff --git a/generated/types.ts b/generated/types.ts index b2a5f4f4..fcac3612 100644 --- a/generated/types.ts +++ b/generated/types.ts @@ -45278,6 +45278,9 @@ export default { ] }, "maps": { + "deleted_at": [ + 4954 + ], "active_pool": [ 6 ], @@ -45525,6 +45528,9 @@ export default { ] }, "maps_bool_exp": { + "deleted_at": [ + 4955 + ], "_and": [ 2658 ], @@ -45582,6 +45588,9 @@ export default { }, "maps_constraint": {}, "maps_insert_input": { + "deleted_at": [ + 4954 + ], "active_pool": [ 6 ], @@ -45623,6 +45632,9 @@ export default { ] }, "maps_max_fields": { + "deleted_at": [ + 4954 + ], "id": [ 5454 ], @@ -45646,6 +45658,9 @@ export default { ] }, "maps_max_order_by": { + "deleted_at": [ + 3373 + ], "id": [ 3373 ], @@ -45669,6 +45684,9 @@ export default { ] }, "maps_min_fields": { + "deleted_at": [ + 4954 + ], "id": [ 5454 ], @@ -45692,6 +45710,9 @@ export default { ] }, "maps_min_order_by": { + "deleted_at": [ + 3373 + ], "id": [ 3373 ], @@ -45751,6 +45772,9 @@ export default { ] }, "maps_order_by": { + "deleted_at": [ + 3373 + ], "active_pool": [ 3373 ], @@ -45803,6 +45827,9 @@ export default { "maps_select_column_maps_aggregate_bool_exp_bool_and_arguments_columns": {}, "maps_select_column_maps_aggregate_bool_exp_bool_or_arguments_columns": {}, "maps_set_input": { + "deleted_at": [ + 4954 + ], "active_pool": [ 6 ], @@ -45846,6 +45873,9 @@ export default { ] }, "maps_stream_cursor_value_input": { + "deleted_at": [ + 4954 + ], "active_pool": [ 6 ], diff --git a/hasura/enums/maps.sql b/hasura/enums/maps.sql index 5f01527d..422f49f2 100644 --- a/hasura/enums/maps.sql +++ b/hasura/enums/maps.sql @@ -182,7 +182,7 @@ WITH expected_maps AS ( type, array_agg(name ORDER BY name) as expected_map_names FROM maps - WHERE active_pool = true + WHERE active_pool = true AND deleted_at IS NULL GROUP BY type ), existing_pools AS ( @@ -258,6 +258,7 @@ begin (p.type = 'Wingman' AND m.type = 'Wingman' AND m.active_pool = 'true') OR (p.type = 'Duel' AND m.type = 'Duel' AND m.active_pool = 'true') ) + WHERE m.deleted_at IS NULL ON CONFLICT DO NOTHING; return true; diff --git a/hasura/metadata/databases/default/tables/public_maps.yaml b/hasura/metadata/databases/default/tables/public_maps.yaml index d73afff9..efafa98f 100644 --- a/hasura/metadata/databases/default/tables/public_maps.yaml +++ b/hasura/metadata/databases/default/tables/public_maps.yaml @@ -47,6 +47,7 @@ select_permissions: - type - workshop_map_id - id + - deleted_at filter: {} comment: "" update_permissions: @@ -61,11 +62,7 @@ update_permissions: - poster - type - workshop_map_id + - deleted_at filter: {} check: {} comment: "" -delete_permissions: - - role: administrator - permission: - filter: {} - comment: "" diff --git a/hasura/migrations/default/1879000000000_maps_soft_delete/down.sql b/hasura/migrations/default/1879000000000_maps_soft_delete/down.sql new file mode 100644 index 00000000..5481cf45 --- /dev/null +++ b/hasura/migrations/default/1879000000000_maps_soft_delete/down.sql @@ -0,0 +1 @@ +alter table "public"."maps" drop column if exists "deleted_at"; diff --git a/hasura/migrations/default/1879000000000_maps_soft_delete/up.sql b/hasura/migrations/default/1879000000000_maps_soft_delete/up.sql new file mode 100644 index 00000000..b459e96d --- /dev/null +++ b/hasura/migrations/default/1879000000000_maps_soft_delete/up.sql @@ -0,0 +1 @@ +alter table "public"."maps" add column if not exists "deleted_at" timestamptz null; diff --git a/hasura/triggers/maps.sql b/hasura/triggers/maps.sql new file mode 100644 index 00000000..f8a432a9 --- /dev/null +++ b/hasura/triggers/maps.sql @@ -0,0 +1,19 @@ +CREATE OR REPLACE FUNCTION public.tau_maps_soft_delete() RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + DELETE FROM _map_pool WHERE map_id = NEW.id; + RETURN NULL; + END IF; + + IF OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL THEN + PERFORM update_map_pools(); + END IF; + + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS tau_maps_soft_delete ON public.maps; +CREATE TRIGGER tau_maps_soft_delete AFTER UPDATE OF deleted_at ON public.maps FOR EACH ROW EXECUTE FUNCTION public.tau_maps_soft_delete(); diff --git a/hasura/triggers/v_pool_maps.sql b/hasura/triggers/v_pool_maps.sql index aa9b37a1..c2663c1c 100644 --- a/hasura/triggers/v_pool_maps.sql +++ b/hasura/triggers/v_pool_maps.sql @@ -2,6 +2,10 @@ CREATE OR REPLACE FUNCTION public.ti_v_pool_maps() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN + IF EXISTS (SELECT 1 FROM maps WHERE id = NEW.id AND deleted_at IS NOT NULL) THEN + RAISE EXCEPTION 'Map % has been deleted and cannot be added to a map pool', NEW.id; + END IF; + INSERT INTO _map_pool (map_id, map_pool_id) VALUES (NEW.id, NEW.map_pool_id); RETURN NULL; diff --git a/src/discord-bot/discord-bot.service.ts b/src/discord-bot/discord-bot.service.ts index 1747ee45..e88a7824 100644 --- a/src/discord-bot/discord-bot.service.ts +++ b/src/discord-bot/discord-bot.service.ts @@ -266,6 +266,9 @@ export class DiscordBotService { type: { _eq: type, }, + deleted_at: { + _is_null: true, + }, }, }, id: true, diff --git a/src/discord-bot/interactions/ScheduleMatch.ts b/src/discord-bot/interactions/ScheduleMatch.ts index dafd2679..08a928fe 100644 --- a/src/discord-bot/interactions/ScheduleMatch.ts +++ b/src/discord-bot/interactions/ScheduleMatch.ts @@ -489,6 +489,9 @@ export default class ScheduleMatch extends DiscordInteraction { type: { _eq: type, }, + deleted_at: { + _is_null: true, + }, }, }, id: true, diff --git a/test/maps-soft-delete.spec.ts b/test/maps-soft-delete.spec.ts new file mode 100644 index 00000000..06db52eb --- /dev/null +++ b/test/maps-soft-delete.spec.ts @@ -0,0 +1,160 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// Maps are soft deleted (maps.deleted_at) so the rows that finished matches +// point at survive. Covers what the DB layer guarantees around that flag: +// a deleted map leaves every pool, the seed-pool sync ignores it, pools +// refuse it, and restoring puts an active-duty map back where it was. +describe("maps soft delete (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + + const MAP = "de_softdelete_test"; + + beforeAll(async () => { + db = await bootMigratedDb("MapsSoftDeleteTest"); + postgres = db.postgres; + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM maps WHERE name = $1", [MAP]); + await postgres.query( + "DELETE FROM map_pools WHERE type = 'Custom' AND NOT EXISTS (SELECT 1 FROM _map_pool WHERE map_pool_id = map_pools.id)", + ); + await postgres.query( + "INSERT INTO settings (name, value) VALUES ('update_map_pools', 'true') ON CONFLICT (name) DO UPDATE SET value = 'true'", + ); + }); + + const createMap = async (activePool = false) => { + const [map] = await postgres.query>( + `INSERT INTO maps (name, type, active_pool, enabled) + VALUES ($1, 'Competitive', $2, true) RETURNING id`, + [MAP, activePool], + ); + return map.id; + }; + + const poolIdsOf = async (mapId: string) => { + const rows = await postgres.query>( + "SELECT map_pool_id FROM _map_pool WHERE map_id = $1", + [mapId], + ); + return rows.map((row) => row.map_pool_id); + }; + + const seedPoolId = async () => { + const [pool] = await postgres.query>( + "SELECT id FROM map_pools WHERE type = 'Competitive' AND seed = true AND enabled = true LIMIT 1", + ); + return pool.id; + }; + + const softDelete = (mapId: string) => + postgres.query("UPDATE maps SET deleted_at = now() WHERE id = $1", [ + mapId, + ]); + + const restore = (mapId: string) => + postgres.query("UPDATE maps SET deleted_at = NULL WHERE id = $1", [ + mapId, + ]); + + it("drops a soft-deleted map out of every pool it was in", async () => { + const mapId = await createMap(); + const [pool] = await postgres.query>( + "INSERT INTO map_pools (type) VALUES ('Custom') RETURNING id", + ); + await postgres.query( + "INSERT INTO _map_pool (map_pool_id, map_id) VALUES ($1, $2)", + [pool.id, mapId], + ); + expect(await poolIdsOf(mapId)).toEqual([pool.id]); + + await softDelete(mapId); + + expect(await poolIdsOf(mapId)).toEqual([]); + const [row] = await postgres.query>( + "SELECT deleted_at FROM maps WHERE id = $1", + [mapId], + ); + expect(row.deleted_at).not.toBeNull(); + }); + + it("keeps a soft-deleted active-duty map out of the seed pool sync", async () => { + const mapId = await createMap(true); + await postgres.query("SELECT update_map_pools()"); + expect(await poolIdsOf(mapId)).toContain(await seedPoolId()); + + await softDelete(mapId); + expect(await poolIdsOf(mapId)).toEqual([]); + + await postgres.query("SELECT update_map_pools()"); + expect(await poolIdsOf(mapId)).toEqual([]); + }); + + it("refuses to add a soft-deleted map to a pool", async () => { + const mapId = await createMap(); + await softDelete(mapId); + const [pool] = await postgres.query>( + "INSERT INTO map_pools (type) VALUES ('Custom') RETURNING id", + ); + + await expect( + postgres.query( + "INSERT INTO v_pool_maps (map_pool_id, id) VALUES ($1, $2)", + [pool.id, mapId], + ), + ).rejects.toThrow(/has been deleted/); + }); + + it("restoring an active-duty map puts it back in the seed pool", async () => { + const mapId = await createMap(true); + await postgres.query("SELECT update_map_pools()"); + const seed = await seedPoolId(); + expect(await poolIdsOf(mapId)).toContain(seed); + + await softDelete(mapId); + expect(await poolIdsOf(mapId)).toEqual([]); + + await restore(mapId); + expect(await poolIdsOf(mapId)).toContain(seed); + }); + + it("restoring keeps the same row so match history still resolves", async () => { + const mapId = await createMap(); + await softDelete(mapId); + await restore(mapId); + + const rows = await postgres.query< + Array<{ id: string; deleted_at: Date | null }> + >("SELECT id, deleted_at FROM maps WHERE name = $1", [MAP]); + expect(rows).toEqual([{ id: mapId, deleted_at: null }]); + }); + + it("the map seed upsert leaves an admin's deletion alone", async () => { + const mapId = await createMap(); + await softDelete(mapId); + + await postgres.query( + `INSERT INTO maps (name, type, active_pool, workshop_map_id, poster, patch, label) + VALUES ($1, 'Competitive', true, NULL, '/poster.webp', NULL, NULL) + ON CONFLICT (name, type) DO UPDATE SET + active_pool = EXCLUDED.active_pool, + poster = EXCLUDED.poster`, + [MAP], + ); + + const [row] = await postgres.query< + Array<{ id: string; deleted_at: Date | null; poster: string }> + >("SELECT id, deleted_at, poster FROM maps WHERE name = $1", [MAP]); + expect(row.id).toBe(mapId); + expect(row.deleted_at).not.toBeNull(); + expect(row.poster).toBe("/poster.webp"); + expect(await poolIdsOf(mapId)).toEqual([]); + }); +}); From 38486256d50976dd85ff1d91d9c8ac331a64565d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 14:32:14 -0400 Subject: [PATCH 05/10] wip --- test/maps-soft-delete.spec.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/maps-soft-delete.spec.ts b/test/maps-soft-delete.spec.ts index 06db52eb..8f002d53 100644 --- a/test/maps-soft-delete.spec.ts +++ b/test/maps-soft-delete.spec.ts @@ -55,14 +55,10 @@ describe("maps soft delete (SQL-driven)", () => { }; const softDelete = (mapId: string) => - postgres.query("UPDATE maps SET deleted_at = now() WHERE id = $1", [ - mapId, - ]); + postgres.query("UPDATE maps SET deleted_at = now() WHERE id = $1", [mapId]); const restore = (mapId: string) => - postgres.query("UPDATE maps SET deleted_at = NULL WHERE id = $1", [ - mapId, - ]); + postgres.query("UPDATE maps SET deleted_at = NULL WHERE id = $1", [mapId]); it("drops a soft-deleted map out of every pool it was in", async () => { const mapId = await createMap(); From a5b9eb961fd44a0277096697b384b3ce7b0a861f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 14:33:55 -0400 Subject: [PATCH 06/10] wip --- generated/types.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/generated/types.ts b/generated/types.ts index fcac3612..c4301708 100644 --- a/generated/types.ts +++ b/generated/types.ts @@ -45278,12 +45278,12 @@ export default { ] }, "maps": { - "deleted_at": [ - 4954 - ], "active_pool": [ 6 ], + "deleted_at": [ + 4954 + ], "e_match_type": [ 1177 ], @@ -45528,9 +45528,6 @@ export default { ] }, "maps_bool_exp": { - "deleted_at": [ - 4955 - ], "_and": [ 2658 ], @@ -45543,6 +45540,9 @@ export default { "active_pool": [ 7 ], + "deleted_at": [ + 4955 + ], "e_match_type": [ 1180 ], @@ -45588,12 +45588,12 @@ export default { }, "maps_constraint": {}, "maps_insert_input": { - "deleted_at": [ - 4954 - ], "active_pool": [ 6 ], + "deleted_at": [ + 4954 + ], "e_match_type": [ 1188 ], @@ -45772,10 +45772,10 @@ export default { ] }, "maps_order_by": { - "deleted_at": [ + "active_pool": [ 3373 ], - "active_pool": [ + "deleted_at": [ 3373 ], "e_match_type": [ @@ -45827,12 +45827,12 @@ export default { "maps_select_column_maps_aggregate_bool_exp_bool_and_arguments_columns": {}, "maps_select_column_maps_aggregate_bool_exp_bool_or_arguments_columns": {}, "maps_set_input": { - "deleted_at": [ - 4954 - ], "active_pool": [ 6 ], + "deleted_at": [ + 4954 + ], "enabled": [ 6 ], @@ -45873,12 +45873,12 @@ export default { ] }, "maps_stream_cursor_value_input": { - "deleted_at": [ - 4954 - ], "active_pool": [ 6 ], + "deleted_at": [ + 4954 + ], "enabled": [ 6 ], From 29be59d0292c63e70ad6c092e4c3ab082da7a93d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 14:40:11 -0400 Subject: [PATCH 07/10] wip --- .../public_game_server_node_plugins.yaml | 1 + .../down.sql | 1 + .../up.sql | 4 ++ src/game-plugins/game-plugins.controller.ts | 1 + src/game-plugins/game-plugins.service.ts | 15 ++++-- test/game-plugin-install-state.spec.ts | 46 +++++++++++++++++++ 6 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 hasura/migrations/default/1879000001000_game_server_node_plugins_path/down.sql create mode 100644 hasura/migrations/default/1879000001000_game_server_node_plugins_path/up.sql diff --git a/hasura/metadata/databases/default/tables/public_game_server_node_plugins.yaml b/hasura/metadata/databases/default/tables/public_game_server_node_plugins.yaml index 433522ec..cf9f95a6 100644 --- a/hasura/metadata/databases/default/tables/public_game_server_node_plugins.yaml +++ b/hasura/metadata/databases/default/tables/public_game_server_node_plugins.yaml @@ -29,6 +29,7 @@ select_permissions: - source - detected - last_error + - path - installed_at - created_at - updated_at diff --git a/hasura/migrations/default/1879000001000_game_server_node_plugins_path/down.sql b/hasura/migrations/default/1879000001000_game_server_node_plugins_path/down.sql new file mode 100644 index 00000000..3cd8f0c9 --- /dev/null +++ b/hasura/migrations/default/1879000001000_game_server_node_plugins_path/down.sql @@ -0,0 +1 @@ +alter table "public"."game_server_node_plugins" drop column if exists "path"; diff --git a/hasura/migrations/default/1879000001000_game_server_node_plugins_path/up.sql b/hasura/migrations/default/1879000001000_game_server_node_plugins_path/up.sql new file mode 100644 index 00000000..54295c5a --- /dev/null +++ b/hasura/migrations/default/1879000001000_game_server_node_plugins_path/up.sql @@ -0,0 +1,4 @@ +-- Where the node says the plugin's files actually are, relative to its +-- custom-plugins root. The catalog cannot know this for a csgo-layout release, +-- and guessing sent operators to a configs directory that may never exist. +alter table "public"."game_server_node_plugins" add column if not exists "path" text null; diff --git a/src/game-plugins/game-plugins.controller.ts b/src/game-plugins/game-plugins.controller.ts index e7f44f1c..955686cb 100644 --- a/src/game-plugins/game-plugins.controller.ts +++ b/src/game-plugins/game-plugins.controller.ts @@ -69,6 +69,7 @@ export class GamePluginsController { version: string | null; runtime: string | null; source: "managed" | "manual"; + path?: string | null; }>; }, ) { diff --git a/src/game-plugins/game-plugins.service.ts b/src/game-plugins/game-plugins.service.ts index bb560c42..58d6e3b7 100644 --- a/src/game-plugins/game-plugins.service.ts +++ b/src/game-plugins/game-plugins.service.ts @@ -608,16 +608,17 @@ export class GamePluginsService { version: string | null; runtime: string | null; source: "managed" | "manual"; + path?: string | null; }>, ): Promise { for (const plugin of reported) { await this.postgres.query( `INSERT INTO public.game_server_node_plugins (game_server_node_id, plugin_slug, runtime, version, detected_version, - source, detected, status, updated_at) + source, detected, status, path, updated_at) SELECT n.id, $2, COALESCE($3::text, n.pin_plugin_runtime, active_plugin_runtime()), - $4, $4, $5, true, 'Installed', now() + $4, $4, $5, true, 'Installed', $6, now() FROM public.game_server_nodes n WHERE n.id = $1 ON CONFLICT (game_server_node_id, plugin_slug) DO UPDATE SET @@ -627,9 +628,17 @@ export class GamePluginsService { status = 'Installed', source = EXCLUDED.source, runtime = EXCLUDED.runtime, + path = EXCLUDED.path, last_error = null, updated_at = now()`, - [nodeId, plugin.slug, plugin.runtime, plugin.version, plugin.source], + [ + nodeId, + plugin.slug, + plugin.runtime, + plugin.version, + plugin.source, + plugin.path ?? null, + ], ); } diff --git a/test/game-plugin-install-state.spec.ts b/test/game-plugin-install-state.spec.ts index bebe7990..34dceffe 100644 --- a/test/game-plugin-install-state.spec.ts +++ b/test/game-plugin-install-state.spec.ts @@ -1,4 +1,5 @@ import { PostgresService } from "./../src/postgres/postgres.service"; +import { GamePluginsService } from "./../src/game-plugins/game-plugins.service"; import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; // Installing states intent and nodes converge to it, so "installed" is a count @@ -180,4 +181,49 @@ describe("game plugin install state (SQL-driven)", () => { expect(Number(row.installed)).toEqual(1); expect(Number(row.target)).toEqual(2); }); + + // The catalog cannot say where a csgo-layout release lands, so the node + // reports it and the panel opens that rather than a guessed configs folder. + it("records where the node says the plugin lives", async () => { + const service = new GamePluginsService( + { warn: jest.fn(), log: jest.fn() } as never, + {} as never, + postgres, + {} as never, + {} as never, + ); + await addNode("node-1"); + await request(); + + await service.recordNodeState("node-1", [ + { + slug: "retakes", + version: "1.0.0", + runtime: "swiftlys2", + source: "managed", + path: "addons/swiftlys2/plugins/Retakes", + }, + ]); + + const read = async () => { + const [row] = await postgres.query>( + `SELECT path FROM game_server_node_plugins WHERE game_server_node_id = 'node-1'`, + ); + return row.path; + }; + + expect(await read()).toEqual("addons/swiftlys2/plugins/Retakes"); + + await service.recordNodeState("node-1", [ + { + slug: "retakes", + version: "1.0.0", + runtime: "swiftlys2", + source: "managed", + path: null, + }, + ]); + + expect(await read()).toBeNull(); + }); }); From 64fa7bfa050963c0faad61a1093ad2c7bb9ff69c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 14:41:56 -0400 Subject: [PATCH 08/10] wip --- generated/schema.graphql | 16 ++++++++++++++++ generated/schema.ts | 26 +++++++++++++++++--------- generated/types.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/generated/schema.graphql b/generated/schema.graphql index fe3e6953..4c84f29d 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -22181,6 +22181,7 @@ type game_server_node_plugins { id: uuid! installed_at: timestamptz last_error: String + path: String """An object relationship""" plugin: game_plugins @@ -22271,6 +22272,7 @@ input game_server_node_plugins_bool_exp { id: uuid_comparison_exp installed_at: timestamptz_comparison_exp last_error: String_comparison_exp + path: String_comparison_exp plugin: game_plugins_bool_exp plugin_slug: String_comparison_exp runtime: e_plugin_runtimes_enum_comparison_exp @@ -22308,6 +22310,7 @@ input game_server_node_plugins_insert_input { id: uuid installed_at: timestamptz last_error: String + path: String plugin: game_plugins_obj_rel_insert_input plugin_slug: String runtime: e_plugin_runtimes_enum @@ -22325,6 +22328,7 @@ type game_server_node_plugins_max_fields { id: uuid installed_at: timestamptz last_error: String + path: String plugin_slug: String source: String updated_at: timestamptz @@ -22341,6 +22345,7 @@ input game_server_node_plugins_max_order_by { id: order_by installed_at: order_by last_error: order_by + path: order_by plugin_slug: order_by source: order_by updated_at: order_by @@ -22355,6 +22360,7 @@ type game_server_node_plugins_min_fields { id: uuid installed_at: timestamptz last_error: String + path: String plugin_slug: String source: String updated_at: timestamptz @@ -22371,6 +22377,7 @@ input game_server_node_plugins_min_order_by { id: order_by installed_at: order_by last_error: order_by + path: order_by plugin_slug: order_by source: order_by updated_at: order_by @@ -22408,6 +22415,7 @@ input game_server_node_plugins_order_by { id: order_by installed_at: order_by last_error: order_by + path: order_by plugin: game_plugins_order_by plugin_slug: order_by runtime: order_by @@ -22450,6 +22458,9 @@ enum game_server_node_plugins_select_column { """column name""" last_error + """column name""" + path + """column name""" plugin_slug @@ -22497,6 +22508,7 @@ input game_server_node_plugins_set_input { id: uuid installed_at: timestamptz last_error: String + path: String plugin_slug: String runtime: e_plugin_runtimes_enum source: String @@ -22526,6 +22538,7 @@ input game_server_node_plugins_stream_cursor_value_input { id: uuid installed_at: timestamptz last_error: String + path: String plugin_slug: String runtime: e_plugin_runtimes_enum source: String @@ -22562,6 +22575,9 @@ enum game_server_node_plugins_update_column { """column name""" last_error + """column name""" + path + """column name""" plugin_slug diff --git a/generated/schema.ts b/generated/schema.ts index 2c5f2b3f..97a551a3 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -8199,6 +8199,7 @@ export interface game_server_node_plugins { id: Scalars['uuid'] installed_at: (Scalars['timestamptz'] | null) last_error: (Scalars['String'] | null) + path: (Scalars['String'] | null) /** An object relationship */ plugin: (game_plugins | null) plugin_slug: Scalars['String'] @@ -8240,6 +8241,7 @@ export interface game_server_node_plugins_max_fields { id: (Scalars['uuid'] | null) installed_at: (Scalars['timestamptz'] | null) last_error: (Scalars['String'] | null) + path: (Scalars['String'] | null) plugin_slug: (Scalars['String'] | null) source: (Scalars['String'] | null) updated_at: (Scalars['timestamptz'] | null) @@ -8256,6 +8258,7 @@ export interface game_server_node_plugins_min_fields { id: (Scalars['uuid'] | null) installed_at: (Scalars['timestamptz'] | null) last_error: (Scalars['String'] | null) + path: (Scalars['String'] | null) plugin_slug: (Scalars['String'] | null) source: (Scalars['String'] | null) updated_at: (Scalars['timestamptz'] | null) @@ -8275,7 +8278,7 @@ export interface game_server_node_plugins_mutation_response { /** select columns of table "game_server_node_plugins" */ -export type game_server_node_plugins_select_column = 'channel' | 'created_at' | 'detected' | 'detected_version' | 'game_server_node_id' | 'id' | 'installed_at' | 'last_error' | 'plugin_slug' | 'runtime' | 'source' | 'status' | 'updated_at' | 'version' +export type game_server_node_plugins_select_column = 'channel' | 'created_at' | 'detected' | 'detected_version' | 'game_server_node_id' | 'id' | 'installed_at' | 'last_error' | 'path' | 'plugin_slug' | 'runtime' | 'source' | 'status' | 'updated_at' | 'version' /** select "game_server_node_plugins_aggregate_bool_exp_bool_and_arguments_columns" columns of table "game_server_node_plugins" */ @@ -8287,7 +8290,7 @@ export type game_server_node_plugins_select_column_game_server_node_plugins_aggr /** update columns of table "game_server_node_plugins" */ -export type game_server_node_plugins_update_column = 'channel' | 'created_at' | 'detected' | 'detected_version' | 'game_server_node_id' | 'id' | 'installed_at' | 'last_error' | 'plugin_slug' | 'runtime' | 'source' | 'status' | 'updated_at' | 'version' +export type game_server_node_plugins_update_column = 'channel' | 'created_at' | 'detected' | 'detected_version' | 'game_server_node_id' | 'id' | 'installed_at' | 'last_error' | 'path' | 'plugin_slug' | 'runtime' | 'source' | 'status' | 'updated_at' | 'version' /** columns and relationships of "game_server_nodes" */ @@ -51308,6 +51311,7 @@ export interface game_server_node_pluginsGenqlSelection{ id?: boolean | number installed_at?: boolean | number last_error?: boolean | number + path?: boolean | number /** An object relationship */ plugin?: game_pluginsGenqlSelection plugin_slug?: boolean | number @@ -51359,11 +51363,11 @@ on_conflict?: (game_server_node_plugins_on_conflict | null)} /** Boolean expression to filter rows from the table "game_server_node_plugins". All fields are combined with a logical 'AND'. */ -export interface game_server_node_plugins_bool_exp {_and?: (game_server_node_plugins_bool_exp[] | null),_not?: (game_server_node_plugins_bool_exp | null),_or?: (game_server_node_plugins_bool_exp[] | null),channel?: (e_game_plugin_channels_enum_comparison_exp | null),created_at?: (timestamptz_comparison_exp | null),detected?: (Boolean_comparison_exp | null),detected_version?: (String_comparison_exp | null),game_server_node?: (game_server_nodes_bool_exp | null),game_server_node_id?: (String_comparison_exp | null),id?: (uuid_comparison_exp | null),installed_at?: (timestamptz_comparison_exp | null),last_error?: (String_comparison_exp | null),plugin?: (game_plugins_bool_exp | null),plugin_slug?: (String_comparison_exp | null),runtime?: (e_plugin_runtimes_enum_comparison_exp | null),source?: (String_comparison_exp | null),status?: (e_game_plugin_install_statuses_enum_comparison_exp | null),updated_at?: (timestamptz_comparison_exp | null),version?: (String_comparison_exp | null)} +export interface game_server_node_plugins_bool_exp {_and?: (game_server_node_plugins_bool_exp[] | null),_not?: (game_server_node_plugins_bool_exp | null),_or?: (game_server_node_plugins_bool_exp[] | null),channel?: (e_game_plugin_channels_enum_comparison_exp | null),created_at?: (timestamptz_comparison_exp | null),detected?: (Boolean_comparison_exp | null),detected_version?: (String_comparison_exp | null),game_server_node?: (game_server_nodes_bool_exp | null),game_server_node_id?: (String_comparison_exp | null),id?: (uuid_comparison_exp | null),installed_at?: (timestamptz_comparison_exp | null),last_error?: (String_comparison_exp | null),path?: (String_comparison_exp | null),plugin?: (game_plugins_bool_exp | null),plugin_slug?: (String_comparison_exp | null),runtime?: (e_plugin_runtimes_enum_comparison_exp | null),source?: (String_comparison_exp | null),status?: (e_game_plugin_install_statuses_enum_comparison_exp | null),updated_at?: (timestamptz_comparison_exp | null),version?: (String_comparison_exp | null)} /** input type for inserting data into table "game_server_node_plugins" */ -export interface game_server_node_plugins_insert_input {channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),detected?: (Scalars['Boolean'] | null),detected_version?: (Scalars['String'] | null),game_server_node?: (game_server_nodes_obj_rel_insert_input | null),game_server_node_id?: (Scalars['String'] | null),id?: (Scalars['uuid'] | null),installed_at?: (Scalars['timestamptz'] | null),last_error?: (Scalars['String'] | null),plugin?: (game_plugins_obj_rel_insert_input | null),plugin_slug?: (Scalars['String'] | null),runtime?: (e_plugin_runtimes_enum | null),source?: (Scalars['String'] | null),status?: (e_game_plugin_install_statuses_enum | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} +export interface game_server_node_plugins_insert_input {channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),detected?: (Scalars['Boolean'] | null),detected_version?: (Scalars['String'] | null),game_server_node?: (game_server_nodes_obj_rel_insert_input | null),game_server_node_id?: (Scalars['String'] | null),id?: (Scalars['uuid'] | null),installed_at?: (Scalars['timestamptz'] | null),last_error?: (Scalars['String'] | null),path?: (Scalars['String'] | null),plugin?: (game_plugins_obj_rel_insert_input | null),plugin_slug?: (Scalars['String'] | null),runtime?: (e_plugin_runtimes_enum | null),source?: (Scalars['String'] | null),status?: (e_game_plugin_install_statuses_enum | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} /** aggregate max on columns */ @@ -51374,6 +51378,7 @@ export interface game_server_node_plugins_max_fieldsGenqlSelection{ id?: boolean | number installed_at?: boolean | number last_error?: boolean | number + path?: boolean | number plugin_slug?: boolean | number source?: boolean | number updated_at?: boolean | number @@ -51384,7 +51389,7 @@ export interface game_server_node_plugins_max_fieldsGenqlSelection{ /** order by max() on columns of table "game_server_node_plugins" */ -export interface game_server_node_plugins_max_order_by {created_at?: (order_by | null),detected_version?: (order_by | null),game_server_node_id?: (order_by | null),id?: (order_by | null),installed_at?: (order_by | null),last_error?: (order_by | null),plugin_slug?: (order_by | null),source?: (order_by | null),updated_at?: (order_by | null),version?: (order_by | null)} +export interface game_server_node_plugins_max_order_by {created_at?: (order_by | null),detected_version?: (order_by | null),game_server_node_id?: (order_by | null),id?: (order_by | null),installed_at?: (order_by | null),last_error?: (order_by | null),path?: (order_by | null),plugin_slug?: (order_by | null),source?: (order_by | null),updated_at?: (order_by | null),version?: (order_by | null)} /** aggregate min on columns */ @@ -51395,6 +51400,7 @@ export interface game_server_node_plugins_min_fieldsGenqlSelection{ id?: boolean | number installed_at?: boolean | number last_error?: boolean | number + path?: boolean | number plugin_slug?: boolean | number source?: boolean | number updated_at?: boolean | number @@ -51405,7 +51411,7 @@ export interface game_server_node_plugins_min_fieldsGenqlSelection{ /** order by min() on columns of table "game_server_node_plugins" */ -export interface game_server_node_plugins_min_order_by {created_at?: (order_by | null),detected_version?: (order_by | null),game_server_node_id?: (order_by | null),id?: (order_by | null),installed_at?: (order_by | null),last_error?: (order_by | null),plugin_slug?: (order_by | null),source?: (order_by | null),updated_at?: (order_by | null),version?: (order_by | null)} +export interface game_server_node_plugins_min_order_by {created_at?: (order_by | null),detected_version?: (order_by | null),game_server_node_id?: (order_by | null),id?: (order_by | null),installed_at?: (order_by | null),last_error?: (order_by | null),path?: (order_by | null),plugin_slug?: (order_by | null),source?: (order_by | null),updated_at?: (order_by | null),version?: (order_by | null)} /** response of any mutation on the table "game_server_node_plugins" */ @@ -51424,7 +51430,7 @@ export interface game_server_node_plugins_on_conflict {constraint: game_server_n /** Ordering options when selecting data from "game_server_node_plugins". */ -export interface game_server_node_plugins_order_by {channel?: (order_by | null),created_at?: (order_by | null),detected?: (order_by | null),detected_version?: (order_by | null),game_server_node?: (game_server_nodes_order_by | null),game_server_node_id?: (order_by | null),id?: (order_by | null),installed_at?: (order_by | null),last_error?: (order_by | null),plugin?: (game_plugins_order_by | null),plugin_slug?: (order_by | null),runtime?: (order_by | null),source?: (order_by | null),status?: (order_by | null),updated_at?: (order_by | null),version?: (order_by | null)} +export interface game_server_node_plugins_order_by {channel?: (order_by | null),created_at?: (order_by | null),detected?: (order_by | null),detected_version?: (order_by | null),game_server_node?: (game_server_nodes_order_by | null),game_server_node_id?: (order_by | null),id?: (order_by | null),installed_at?: (order_by | null),last_error?: (order_by | null),path?: (order_by | null),plugin?: (game_plugins_order_by | null),plugin_slug?: (order_by | null),runtime?: (order_by | null),source?: (order_by | null),status?: (order_by | null),updated_at?: (order_by | null),version?: (order_by | null)} /** primary key columns input for table: game_server_node_plugins */ @@ -51432,7 +51438,7 @@ export interface game_server_node_plugins_pk_columns_input {id: Scalars['uuid']} /** input type for updating data in table "game_server_node_plugins" */ -export interface game_server_node_plugins_set_input {channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),detected?: (Scalars['Boolean'] | null),detected_version?: (Scalars['String'] | null),game_server_node_id?: (Scalars['String'] | null),id?: (Scalars['uuid'] | null),installed_at?: (Scalars['timestamptz'] | null),last_error?: (Scalars['String'] | null),plugin_slug?: (Scalars['String'] | null),runtime?: (e_plugin_runtimes_enum | null),source?: (Scalars['String'] | null),status?: (e_game_plugin_install_statuses_enum | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} +export interface game_server_node_plugins_set_input {channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),detected?: (Scalars['Boolean'] | null),detected_version?: (Scalars['String'] | null),game_server_node_id?: (Scalars['String'] | null),id?: (Scalars['uuid'] | null),installed_at?: (Scalars['timestamptz'] | null),last_error?: (Scalars['String'] | null),path?: (Scalars['String'] | null),plugin_slug?: (Scalars['String'] | null),runtime?: (e_plugin_runtimes_enum | null),source?: (Scalars['String'] | null),status?: (e_game_plugin_install_statuses_enum | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} /** Streaming cursor of the table "game_server_node_plugins" */ @@ -51444,7 +51450,7 @@ ordering?: (cursor_ordering | null)} /** Initial value of the column from where the streaming should start */ -export interface game_server_node_plugins_stream_cursor_value_input {channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),detected?: (Scalars['Boolean'] | null),detected_version?: (Scalars['String'] | null),game_server_node_id?: (Scalars['String'] | null),id?: (Scalars['uuid'] | null),installed_at?: (Scalars['timestamptz'] | null),last_error?: (Scalars['String'] | null),plugin_slug?: (Scalars['String'] | null),runtime?: (e_plugin_runtimes_enum | null),source?: (Scalars['String'] | null),status?: (e_game_plugin_install_statuses_enum | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} +export interface game_server_node_plugins_stream_cursor_value_input {channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),detected?: (Scalars['Boolean'] | null),detected_version?: (Scalars['String'] | null),game_server_node_id?: (Scalars['String'] | null),id?: (Scalars['uuid'] | null),installed_at?: (Scalars['timestamptz'] | null),last_error?: (Scalars['String'] | null),path?: (Scalars['String'] | null),plugin_slug?: (Scalars['String'] | null),runtime?: (e_plugin_runtimes_enum | null),source?: (Scalars['String'] | null),status?: (e_game_plugin_install_statuses_enum | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} export interface game_server_node_plugins_updates { /** sets the columns of the filtered rows to the given values */ @@ -127712,6 +127718,7 @@ export const enumGameServerNodePluginsSelectColumn = { id: 'id' as const, installed_at: 'installed_at' as const, last_error: 'last_error' as const, + path: 'path' as const, plugin_slug: 'plugin_slug' as const, runtime: 'runtime' as const, source: 'source' as const, @@ -127737,6 +127744,7 @@ export const enumGameServerNodePluginsUpdateColumn = { id: 'id' as const, installed_at: 'installed_at' as const, last_error: 'last_error' as const, + path: 'path' as const, plugin_slug: 'plugin_slug' as const, runtime: 'runtime' as const, source: 'source' as const, diff --git a/generated/types.ts b/generated/types.ts index c4301708..458188fd 100644 --- a/generated/types.ts +++ b/generated/types.ts @@ -31757,6 +31757,9 @@ export default { "last_error": [ 84 ], + "path": [ + 84 + ], "plugin": [ 2003 ], @@ -31943,6 +31946,9 @@ export default { "last_error": [ 86 ], + "path": [ + 86 + ], "plugin": [ 2008 ], @@ -31997,6 +32003,9 @@ export default { "last_error": [ 84 ], + "path": [ + 84 + ], "plugin": [ 2017 ], @@ -32041,6 +32050,9 @@ export default { "last_error": [ 84 ], + "path": [ + 84 + ], "plugin_slug": [ 84 ], @@ -32076,6 +32088,9 @@ export default { "last_error": [ 3373 ], + "path": [ + 3373 + ], "plugin_slug": [ 3373 ], @@ -32111,6 +32126,9 @@ export default { "last_error": [ 84 ], + "path": [ + 84 + ], "plugin_slug": [ 84 ], @@ -32146,6 +32164,9 @@ export default { "last_error": [ 3373 ], + "path": [ + 3373 + ], "plugin_slug": [ 3373 ], @@ -32215,6 +32236,9 @@ export default { "last_error": [ 3373 ], + "path": [ + 3373 + ], "plugin": [ 2019 ], @@ -32276,6 +32300,9 @@ export default { "last_error": [ 84 ], + "path": [ + 84 + ], "plugin_slug": [ 84 ], @@ -32334,6 +32361,9 @@ export default { "last_error": [ 84 ], + "path": [ + 84 + ], "plugin_slug": [ 84 ], From 64efc8ae05bc6763a91a14bbdafcae6d10078aaa Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 16:16:58 -0400 Subject: [PATCH 09/10] wip --- .../down.sql | 3 + .../up.sql | 12 ++ hasura/triggers/v_pool_maps.sql | 2 +- src/awards/awards.service.ts | 14 +- src/demos/demos.controller.ts | 2 +- src/game-plugins/game-modes.service.ts | 40 ++--- src/invites/invites.controller.ts | 2 +- src/matches/clips/clips.service.ts | 18 +- src/matches/jobs/TournamentReminders.ts | 4 +- src/news/news.service.ts | 4 +- src/notifications/notifications.service.ts | 47 ++++-- .../push/push-notifications.service.spec.ts | 43 ++++- .../push/push-notifications.service.ts | 155 +++++++++++++----- src/steam-presence/steam-presence.service.ts | 2 +- src/telemetry/telemetry.service.ts | 3 + src/telemetry/types/TelemetryPayload.ts | 3 +- src/tournaments/tournaments.controller.ts | 2 +- test/notifications.spec.ts | 70 ++++++++ 18 files changed, 329 insertions(+), 97 deletions(-) create mode 100644 hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/down.sql create mode 100644 hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/up.sql diff --git a/hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/down.sql b/hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/down.sql new file mode 100644 index 00000000..46c5237b --- /dev/null +++ b/hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/down.sql @@ -0,0 +1,3 @@ +-- Which of these counted before is no longer recorded anywhere; the previous +-- rule is not restored. +SELECT 1; diff --git a/hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/up.sql b/hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/up.sql new file mode 100644 index 00000000..8c3c25be --- /dev/null +++ b/hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/up.sql @@ -0,0 +1,12 @@ +-- Every custom mode is now unranked, whatever competitive_safe says. The flag +-- is stamped on the match at insert (tbi_matches_ranking), so matches created +-- under the previous rule -- a competitive_safe mode counted -- are re-stamped +-- here. Spelled out rather than calling match_ranking_for_options: that +-- function lives in hasura/triggers and is applied after migrations, so a +-- fresh database would not have it yet. +UPDATE "public"."matches" m + SET counts_toward_ranking = false + FROM "public"."match_options" mo + WHERE mo.id = m.match_options_id + AND mo.game_mode_id IS NOT NULL + AND m.counts_toward_ranking = true; diff --git a/hasura/triggers/v_pool_maps.sql b/hasura/triggers/v_pool_maps.sql index c2663c1c..2c3c433a 100644 --- a/hasura/triggers/v_pool_maps.sql +++ b/hasura/triggers/v_pool_maps.sql @@ -3,7 +3,7 @@ CREATE OR REPLACE FUNCTION public.ti_v_pool_maps() RETURNS trigger AS $$ BEGIN IF EXISTS (SELECT 1 FROM maps WHERE id = NEW.id AND deleted_at IS NOT NULL) THEN - RAISE EXCEPTION 'Map % has been deleted and cannot be added to a map pool', NEW.id; + RAISE EXCEPTION 'Map % has been deleted and cannot be added to a map pool', NEW.id USING ERRCODE = '22000'; END IF; INSERT INTO _map_pool (map_id, map_pool_id) diff --git a/src/awards/awards.service.ts b/src/awards/awards.service.ts index b7354fc0..430c6eed 100644 --- a/src/awards/awards.service.ts +++ b/src/awards/awards.service.ts @@ -274,7 +274,7 @@ export class AwardsService { role: "user", entity_id: recipientId, steamIds, - ...(award?.image_url ? { data: { image: award.image_url } } : {}), + data: { image: AwardsService.imagePath(award?.image_url) }, }); } catch (error) { this.logger.warn( @@ -666,6 +666,18 @@ export class AwardsService { return Number.isInteger(silhouette) && silhouette >= 0 && silhouette <= 4; } + // image_url is the S3 key (`awards/`, or `trophies/` from before + // the rename); the file is only served at `/avatars/awards/`. + public static imagePath(imageUrl?: string | null): string | null { + if (!imageUrl) { + return null; + } + if (/^https?:\/\//i.test(imageUrl)) { + return imageUrl; + } + return `/avatars/awards/${imageUrl.replace(/^(awards|trophies)\//, "")}`; + } + private buildPath(slug: string, mimetype: string): string { const ext = EXTENSION_BY_MIMETYPE[mimetype] || "png"; const hash = crypto.randomBytes(6).toString("hex"); diff --git a/src/demos/demos.controller.ts b/src/demos/demos.controller.ts index 18279dca..479ff74d 100644 --- a/src/demos/demos.controller.ts +++ b/src/demos/demos.controller.ts @@ -340,7 +340,7 @@ export class DemosController { role: "user", entity_id: matchId, steamIds: players.map((player) => player.steam_id), - ...(image ? { data: { image } } : {}), + data: { image }, }); } catch (error) { this.logger.warn( diff --git a/src/game-plugins/game-modes.service.ts b/src/game-plugins/game-modes.service.ts index 7da89747..bdc43298 100644 --- a/src/game-plugins/game-modes.service.ts +++ b/src/game-plugins/game-modes.service.ts @@ -175,10 +175,12 @@ export class GameModesService { // for that plugin. Decided from the plugins that are actually going to load, // so a mode that does not select the plugin leaves the server compliant. // - // 5stack Ranks is the exception, because it already turns the same setting - // off to render ranks in-game. The ban it risks is against the Steam account, - // not the server, so once ranks is on that risk is taken deployment-wide and - // asking a second time per plugin would be asking about nothing. + // 5stack Ranks stands in for the operator's half, because it already turns + // the same setting off to render ranks in-game. The ban it risks is against + // the Steam account, not the server, so once ranks is on that risk is taken + // deployment-wide and asking a second time per plugin would be asking about + // nothing. It does not stand in for the catalog's half: a plugin that works + // fine under the guidelines still leaves them on. private async withServerGuidelines( mode: ResolvedGameMode | null, ): Promise { @@ -192,21 +194,21 @@ export class GameModesService { .map((entry) => entry.split("@")[0]); const [row] = await this.postgres.query>( - `SELECT ( - EXISTS ( - SELECT 1 - FROM game_plugin_installs i - INNER JOIN game_plugins p ON p.slug = i.plugin_slug - WHERE i.plugin_slug = ANY($1::text[]) - AND i.disable_server_guidelines = true - AND p.requires_server_guidelines_disabled = true - ) - OR EXISTS ( - SELECT 1 FROM settings - WHERE name IN ('fivestack_ranks_matches', - 'fivestack_ranks_tournaments') - AND value = 'true' - ) + `SELECT EXISTS ( + SELECT 1 + FROM game_plugin_installs i + INNER JOIN game_plugins p ON p.slug = i.plugin_slug + WHERE i.plugin_slug = ANY($1::text[]) + AND p.requires_server_guidelines_disabled = true + AND ( + i.disable_server_guidelines = true + OR EXISTS ( + SELECT 1 FROM settings + WHERE name IN ('fivestack_ranks_matches', + 'fivestack_ranks_tournaments') + AND value = 'true' + ) + ) ) AS disable`, [slugs], ); diff --git a/src/invites/invites.controller.ts b/src/invites/invites.controller.ts index 48d5865c..b9832e97 100644 --- a/src/invites/invites.controller.ts +++ b/src/invites/invites.controller.ts @@ -121,7 +121,7 @@ export class InvitesController { role: "user", entity_id: invite.entityId, steamIds: [invite.steamId], - ...(invite.icon ? { data: { icon: invite.icon } } : {}), + data: { icon: invite.icon }, }); } catch (error) { // The invite itself is already written; losing its notification must not diff --git a/src/matches/clips/clips.service.ts b/src/matches/clips/clips.service.ts index 8f4856b0..f22e2c6b 100644 --- a/src/matches/clips/clips.service.ts +++ b/src/matches/clips/clips.service.ts @@ -1520,7 +1520,7 @@ export class ClipsService { if (body.error) { set.error_message = body.error; } - if (!isBoot && ["completed", "error", "cancelled"].includes(body.status)) { + if (!isBoot && ["done", "error", "cancelled"].includes(body.status)) { // The steam account is held by the batch pod, freed on pod teardown. set.game_server_node_id = null; } @@ -1531,9 +1531,11 @@ export class ClipsService { }, }); - if (!isBoot && body.status === "completed") { - // Renders take minutes and people navigate away, so the finish is the - // whole reason to notify at all. + // The pod posts `done` only after the upload has landed the clip row and + // its poster frame (inline-clip-render.sh), so the notification can carry + // the thumbnail. Renders take minutes and people navigate away, so the + // finish is the whole reason to notify at all. + if (!isBoot && body.status === "done") { void this.notifyClipReady(jobId); } } @@ -1573,9 +1575,11 @@ export class ClipsService { role: "user", entity_id: jobId, steamIds: [job.user_steam_id], - ...(job.thumbnail_clip_id - ? { data: { image: `/clips/${job.thumbnail_clip_id}/thumbnail` } } - : {}), + data: { + image: job.thumbnail_clip_id + ? `/clips/${job.thumbnail_clip_id}/thumbnail` + : null, + }, }); } catch (error) { this.logger.warn(`unable to notify of finished clip ${jobId}`, error); diff --git a/src/matches/jobs/TournamentReminders.ts b/src/matches/jobs/TournamentReminders.ts index e03d93e6..92e20d26 100644 --- a/src/matches/jobs/TournamentReminders.ts +++ b/src/matches/jobs/TournamentReminders.ts @@ -80,9 +80,7 @@ export class TournamentReminders extends WorkerHost { role: "user", entity_id: `${tournament.id}:${tournament.window}`, steamIds: recipients.map((recipient) => recipient.steam_id), - ...((tournament.banner ?? tournament.logo) - ? { data: { image: tournament.banner ?? tournament.logo } } - : {}), + data: { image: tournament.banner ?? tournament.logo }, }); sent++; diff --git a/src/news/news.service.ts b/src/news/news.service.ts index aedf6563..104e474e 100644 --- a/src/news/news.service.ts +++ b/src/news/news.service.ts @@ -54,9 +54,7 @@ export class NewsService { article.title, )} was just published.`, entity_id: article.id, - ...(article.cover_image_url - ? { data: { image: article.cover_image_url } } - : {}), + data: { image: article.cover_image_url }, }); } diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 6721465d..4cabf4ad 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -419,6 +419,20 @@ export class NotificationsService { } } + // Writers hand over `{ image: maybeUndefined }` as is; a row only carries + // `data` when something in it is set. + private static compactData( + data?: NotificationData, + ): NotificationData | undefined { + if (!data) { + return undefined; + } + const entries = Object.entries(data).filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; + } + async notifyPlayers( type: e_notification_types_enum, notification: { @@ -468,6 +482,8 @@ export class NotificationsService { (steamId) => inApp.has(steamId) || pushable.has(steamId), ); + const data = NotificationsService.compactData(notification.data); + if (steamIds.length > 0) { const { insert_notifications } = await this.hasura.mutation({ insert_notifications: { @@ -481,7 +497,7 @@ export class NotificationsService { entity_id: notification.entity_id, actions, in_app: inApp.has(steam_id), - ...(notification.data ? { data: notification.data } : {}), + ...(data ? { data } : {}), ...(notification.deletable === false ? { deletable: false } : {}), })), }, @@ -585,17 +601,6 @@ export class NotificationsService { ); } - // Announce something to the whole player base. - // - // This deliberately writes one row per player rather than a single - // role-targeted row: our notifications select_permissions are a per-role - // enumeration, and `user` only ever matches on steam_id -- so a - // `role: 'user'` broadcast with a null steam_id is visible to nobody. - // - // Written as one INSERT..SELECT because the recipient list is the whole - // players table, and the in-app preference is resolved inline for the same - // reason. See notifyPlayers for why a player who muted the bell still gets a - // row when they have somewhere to be pushed. // The poster for a map, as a push notification image. Posters live in the // web bundle (`/img/maps/screenshots/...`), not behind the API, so they are // qualified here rather than by the push service's API-relative rule. @@ -640,20 +645,33 @@ export class NotificationsService { } } + // Announce something to the whole player base. + // + // This deliberately writes one row per player rather than a single + // role-targeted row: our notifications select_permissions are a per-role + // enumeration, and `user` only ever matches on steam_id -- so a + // `role: 'user'` broadcast with a null steam_id is visible to nobody. + // + // Written as one INSERT..SELECT because the recipient list is the whole + // players table, and the in-app preference is resolved inline for the same + // reason. See notifyPlayers for why a player who muted the bell still gets a + // row when they have somewhere to be pushed. async notifyActivePlayers( type: e_notification_types_enum, notification: { title: string; message: string; entity_id?: string; + data?: NotificationData; }, ) { const key = inAppKeyForType(type); + const data = NotificationsService.compactData(notification.data); const inserted = await this.postgres.query>( - `INSERT INTO public.notifications (type, title, message, role, steam_id, entity_id, in_app) + `INSERT INTO public.notifications (type, title, message, role, steam_id, entity_id, in_app, data) SELECT $1, $2, $3, 'user', p.steam_id, $4, - COALESCE(np.enabled, $7::boolean) + COALESCE(np.enabled, $7::boolean), $8::jsonb FROM public.players p LEFT JOIN public.notification_preferences np ON np.steam_id = p.steam_id @@ -673,6 +691,7 @@ export class NotificationsService { key?.key ?? "", NotificationsService.ACTIVE_PLAYER_WINDOW, key?.defaultEnabled ?? true, + data ? JSON.stringify(data) : null, ], ); diff --git a/src/notifications/push/push-notifications.service.spec.ts b/src/notifications/push/push-notifications.service.spec.ts index 233a097b..0f102cb4 100644 --- a/src/notifications/push/push-notifications.service.spec.ts +++ b/src/notifications/push/push-notifications.service.spec.ts @@ -160,7 +160,7 @@ describe("PushNotificationsService", () => { unread = 4; postgres.query.mockImplementation(async (sql: string, bindings: any[]) => { - if (sql.includes("count(*)::text AS unread")) { + if (sql.includes("AS unread")) { return [{ unread: String(unread) }]; } if (sql.includes("FROM public.notifications\n")) { @@ -823,7 +823,7 @@ describe("PushNotificationsService", () => { it("still sends when the count cannot be taken", async () => { const base = postgres.query.getMockImplementation(); postgres.query.mockImplementation(async (sql: string, bindings: any[]) => - sql.includes("count(*)::text AS unread") + sql.includes("AS unread") ? Promise.reject(new Error("db away")) : base(sql, bindings), ); @@ -851,6 +851,40 @@ describe("PushNotificationsService", () => { }); }); + it("dismisses a bundle by its thread rather than by listing every id", async () => { + // The pending list has no cap -- a flapping node over a night of quiet + // hours leaves dozens of ids -- and a push payload has ~4 KB. + const held = Array.from({ length: 90 }, (_, i) => `id-${i}`); + redis.multi.mockReturnValueOnce(chainableMulti([[null, held]])); + + notificationRow = notification({ + type: "GameNodeStatus", + entity_id: "n-1", + }); + bundled = held.map((id) => ({ + ...notification({ id, type: "GameNodeStatus", entity_id: "n-1" }), + steam_id: "76561100000000001", + subscription_id: "sub-1", + endpoint: subscription("sub-1").endpoint, + p256dh: "p256dh", + auth: "auth", + })); + + await service.sendPending("76561100000000001", "GameNodeStatus:n-1"); + + const payload = payloadOf(0); + expect(payload.actions).toHaveLength(1); + expect(payload.actions[0].operation.variables).toEqual({ + v1: { + type: { _eq: "GameNodeStatus" }, + entity_id: { _eq: "n-1" }, + is_read: { _eq: false }, + }, + v2: { is_read: true }, + }); + expect(JSON.stringify(payload).length).toBeLessThan(2048); + }); + it("turns the bell's buttons into notification buttons", async () => { notificationRow = notification({ type: "ScrimRequestReceived", @@ -883,14 +917,15 @@ describe("PushNotificationsService", () => { "Accept", "Decline", ]); - // The button runs the mutation, then reads the row -- the bell does the - // same two things when one of its buttons is pressed. + // The button runs the mutation, then removes the row -- the bell does + // the same two things when one of its buttons is pressed. expect(actions[0].operation.query).toMatch( /respondToScrimRequest\(request_id:\$v1,accept:\$v2\)\{success\},update_notifications\(/, ); expect(actions[0].operation.variables).toMatchObject({ v1: "req-1", v2: true, + v4: { is_read: true, deleted_at: "now()" }, }); expect(actions[1].operation.variables).toMatchObject({ v2: false }); }); diff --git a/src/notifications/push/push-notifications.service.ts b/src/notifications/push/push-notifications.service.ts index 9cc79367..aa44f466 100644 --- a/src/notifications/push/push-notifications.service.ts +++ b/src/notifications/push/push-notifications.service.ts @@ -38,8 +38,8 @@ export type PushSubscriptionPayload = { export type NotificationData = { threadKey?: string; threadLabel?: string; - icon?: string; - image?: string; + icon?: string | null; + image?: string | null; senderSteamId?: string; }; @@ -152,6 +152,16 @@ const RECIPIENT_ROLES: Record = BROADCAST_ROLES.map((role) => [role, rolesAtOrAbove(role)]), ); +// The same table flattened to (broadcast role, recipient role) pairs, for SQL +// that has to ask "can this player see that broadcast" without a round trip. +const AUDIENCE_PAIRS: Array<[string, string]> = BROADCAST_ROLES.flatMap( + (role) => + RECIPIENT_ROLES[role].map((recipient): [string, string] => [ + role, + recipient, + ]), +); + // Types that fan out to one row per player. The trigger fires per row, so for // these the handler collapses into a single deduped job instead of doing a // query and a send for each of thousands of inserts. @@ -1126,33 +1136,37 @@ export class PushNotificationsService { return `${this.appConfig.apiDomain}/${path.replace(/^\/+/, "")}`; } - private static markReadSelection(ids: string[]) { - return { - update_notifications: { - __args: { - where: { id: { _in: ids } }, - _set: { is_read: true }, - }, - affected_rows: true, - }, - }; - } - - // One button: the mutation the bell would run for it, followed by marking - // the rows read -- which is what the bell does after any of its buttons - // (AppNotifications.vue handleAction), so the push stays in step. + // One button: the mutation the bell would run for it, followed by what the + // bell does to the row afterwards -- Dismiss marks it read + // (NotificationsPanel.vue dismissNotification); a button that answers the + // notification removes it (handleAction -> deleteNotification), otherwise + // the same row would come back in the bell still offering the same buttons. private static pushAction( action: string, title: string, mutation: Record, - ids: string[], + where: Record, ): PushAction { + const answered = Object.keys(mutation).length > 0; + return { action, title, operation: generateMutationOp({ ...mutation, - ...PushNotificationsService.markReadSelection(ids), + update_notifications: { + __args: { + // A row the writer marked undeletable (a scheduled scrim, until it + // is played) stays: the update permission's check rejects a + // deleted_at on it, and one rejected row would roll back the + // answer with it. + where: answered ? { ...where, deletable: { _neq: false } } : where, + _set: answered + ? { is_read: true, deleted_at: "now()" } + : { is_read: true }, + }, + affected_rows: true, + }, } as Parameters[0]), }; } @@ -1165,7 +1179,7 @@ export class PushNotificationsService { count: number, ): PushAction[] { const newest = notifications.at(-1); - const ids = notifications.map(({ id }) => id); + const byId = { id: { _in: notifications.map(({ id }) => id) } }; // Marking a bell row read says nothing about the conversation's own read // cursor, and a "Dismiss" that leaves the thread unread would mislead. @@ -1173,16 +1187,33 @@ export class PushNotificationsService { return []; } - const dismiss = [ - PushNotificationsService.pushAction("dismiss", "Dismiss", {}, ids), - ]; - // A bundle describes several things at once; the only honest button is - // the one that applies to all of them. + // the one that applies to all of them. Selected by thread rather than by + // id: a bundle is every row in one thread (see threadKeyFor), and the id + // list is unbounded -- a night of quiet hours over a flapping node is + // enough to push the payload past the ~4 KB a push service accepts, and + // the summary would be refused outright. if (count > 1 || notifications.length > 1) { - return dismiss; + return [ + PushNotificationsService.pushAction( + "dismiss", + "Dismiss", + {}, + { + type: { _eq: newest.type }, + entity_id: newest.entity_id + ? { _eq: newest.entity_id } + : { _is_null: true }, + is_read: { _eq: false }, + }, + ), + ]; } + const dismiss = [ + PushNotificationsService.pushAction("dismiss", "Dismiss", {}, byId), + ]; + if (newest.actions?.length) { return newest.actions.map(({ label, graphql }, index) => PushNotificationsService.pushAction( @@ -1194,7 +1225,7 @@ export class PushNotificationsService { ...graphql.selection, }, }, - ids, + byId, ), ); } @@ -1217,13 +1248,13 @@ export class PushNotificationsService { "accept", "Accept", { acceptInvite: { __args: variables, success: true } }, - ids, + byId, ), PushNotificationsService.pushAction( "decline", "Decline", { denyInvite: { __args: variables, success: true } }, - ids, + byId, ), ]; } @@ -1239,7 +1270,7 @@ export class PushNotificationsService { success: true, }, }, - ids, + byId, ), ); } @@ -1247,19 +1278,63 @@ export class PushNotificationsService { return dismiss; } - // What the app icon should say once this push lands. Only rows the bell - // would show this player; the bell's own number also counts invites and the - // like, and the page re-syncs the badge from that the moment it is open. + // What the app icon should say once this push lands: the bell's number + // (NotificationStore.unreadNotificationCount), so the badge the push sets + // and the badge the page sets agree. The bell counts its own rows -- the + // player's, and the role broadcasts their role can see -- plus pending + // invites and an unread news article. League schedule tasks are the one + // thing left out; they are derived client-side from the whole season tree. private async unreadCount(steamId: string): Promise { try { const rows = await this.postgres.query>( - `SELECT count(*)::text AS unread - FROM public.notifications - WHERE steam_id = $1 - AND in_app = true - AND is_read = false - AND deleted_at IS NULL`, - [steamId], + `WITH viewer AS ( + SELECT role::text AS role, last_read_news_at + FROM public.players + WHERE steam_id = $1 + ), + audience AS ( + SELECT * FROM unnest($2::text[], $3::text[]) + AS pairs(broadcast_role, recipient_role) + ) + SELECT ( + (SELECT count(*) + FROM public.notifications n, viewer + WHERE n.in_app = true + AND n.is_read = false + AND n.deleted_at IS NULL + AND (n.steam_id = $1 + OR (n.steam_id IS NULL + AND EXISTS (SELECT 1 FROM audience a + WHERE a.broadcast_role = n.role::text + AND a.recipient_role = viewer.role)))) + + (SELECT count(*) FROM public.team_invites WHERE steam_id = $1) + + (SELECT count(*) FROM public.tournament_team_invites + WHERE steam_id = $1) + + (SELECT count(*) + FROM public.draft_game_players dgp + JOIN public.draft_games dg ON dg.id = dgp.draft_game_id + WHERE dgp.steam_id = $1 + AND dgp.status = 'Invited' + AND dg.match_id IS NULL + AND dg.status NOT IN ('Completed', 'Canceled')) + + (SELECT count(*) + FROM (SELECT published_at + FROM public.news_articles + WHERE status = 'published' + ORDER BY published_at DESC NULLS LAST + LIMIT 1) latest, viewer + WHERE EXISTS (SELECT 1 FROM public.settings + WHERE name = 'public.news_enabled' + AND value = 'true') + AND (viewer.last_read_news_at IS NULL + OR latest.published_at IS NULL + OR latest.published_at > viewer.last_read_news_at)) + )::text AS unread`, + [ + steamId, + AUDIENCE_PAIRS.map(([broadcastRole]) => broadcastRole), + AUDIENCE_PAIRS.map(([, recipientRole]) => recipientRole), + ], ); const unread = Number(rows.at(0)?.unread); diff --git a/src/steam-presence/steam-presence.service.ts b/src/steam-presence/steam-presence.service.ts index 9a6beb7b..dbdb2f93 100644 --- a/src/steam-presence/steam-presence.service.ts +++ b/src/steam-presence/steam-presence.service.ts @@ -746,7 +746,7 @@ export class SteamPresenceService role: "user", entity_id: notice.matchId, steamIds: [friend.steam_id], - ...(image ? { data: { image } } : {}), + data: { image }, }) .catch((err) => this.logger.warn( diff --git a/src/telemetry/telemetry.service.ts b/src/telemetry/telemetry.service.ts index 6f1c1f9d..0187f3e3 100644 --- a/src/telemetry/telemetry.service.ts +++ b/src/telemetry/telemetry.service.ts @@ -1451,6 +1451,9 @@ export class TelemetryService { (SELECT count(*) FROM public.game_modes WHERE archived_at IS NULL) AS game_modes, (SELECT count(*) FROM public.game_modes WHERE archived_at IS NULL AND enabled) AS game_modes_enabled, + -- Every custom mode is unranked (match_ranking_for_options), so this + -- is the modes draft lobbies will not offer. The key keeps its old name + -- because payloads already recorded across the fleet sum under it. (SELECT count(*) FROM public.game_modes WHERE archived_at IS NULL AND competitive_safe = false) AS game_modes_unranked `, diff --git a/src/telemetry/types/TelemetryPayload.ts b/src/telemetry/types/TelemetryPayload.ts index 1eb42041..4f839374 100644 --- a/src/telemetry/types/TelemetryPayload.ts +++ b/src/telemetry/types/TelemetryPayload.ts @@ -105,7 +105,8 @@ export type TelemetryPlugins = { modes: number; modes_enabled: number; // Modes that are not competitive_safe, i.e. not offered in draft lobbies. - // (Every custom mode is unranked regardless; the flag only gates drafts.) + // Every custom mode is unranked regardless; the key predates that rule and + // stays so already-recorded payloads keep summing. modes_unranked: number; }; diff --git a/src/tournaments/tournaments.controller.ts b/src/tournaments/tournaments.controller.ts index 40a8c54d..6e95d0f6 100644 --- a/src/tournaments/tournaments.controller.ts +++ b/src/tournaments/tournaments.controller.ts @@ -51,7 +51,7 @@ export class TournamentsController { title: "New tournament", message: `${name} is open for signups.`, entity_id: tournamentId, - ...(image ? { data: { image } } : {}), + data: { image }, }); } catch (error) { this.logger.warn( diff --git a/test/notifications.spec.ts b/test/notifications.spec.ts index 7ef77433..a24bf89a 100644 --- a/test/notifications.spec.ts +++ b/test/notifications.spec.ts @@ -238,6 +238,36 @@ describe("notifications (SQL-driven)", () => { expect(rows).toEqual([{ steam_id: active, in_app: false }]); }); + + it("carries the image the writer hands over, and no data when there is none", async () => { + const active = await activePlayer(); + + await notifications().notifyActivePlayers("NewsPublished", { + title: "New article", + message: "something happened", + entity_id: "article-4", + data: { image: "/news/image/cover.png" }, + }); + await notifications().notifyActivePlayers("NewsPublished", { + title: "Plain article", + message: "nothing to show", + entity_id: "article-5", + data: { image: null }, + }); + + const rows = await postgres.query< + Array<{ entity_id: string; data: Record | null }> + >( + `SELECT entity_id, data FROM notifications WHERE steam_id = $1::bigint + ORDER BY entity_id`, + [active], + ); + + expect(rows).toEqual([ + { entity_id: "article-4", data: { image: "/news/image/cover.png" } }, + { entity_id: "article-5", data: null }, + ]); + }); }); describe("collapseOlderUnread", () => { @@ -569,6 +599,46 @@ describe("notifications (SQL-driven)", () => { expect(webPush.sendNotification).toHaveBeenCalledTimes(1); }); + it("badges what the bell would count, not just the player's own rows", async () => { + // NotificationStore.unreadNotificationCount: own unread rows, the role + // broadcasts the player can see, pending invites. An admin whose only + // unread item is a role broadcast must not have the badge cleared by + // the very push that announces it. + const admin = await fx.player(); + await postgres.query( + `UPDATE players SET role = 'administrator' WHERE steam_id = $1::bigint`, + [admin], + ); + await subscribe(admin); + const team = await fx.team(); + await postgres.query( + `INSERT INTO team_invites (team_id, steam_id, invited_by_player_steam_id) + VALUES ($1::uuid, $2::bigint, $3::bigint)`, + [team.id, admin, team.owner], + ); + await postgres.query( + `INSERT INTO notifications (type, title, message, role, steam_id, entity_id) + VALUES ('GameNodeStatus', 'Node', 'offline', 'match_organizer', NULL, 'n-1')`, + ); + const [row] = await postgres.query>( + `INSERT INTO notifications (type, title, message, role, steam_id, entity_id) + VALUES ('PlayerSanctioned', 'Ban', 'x', 'administrator', NULL, 'p-1') + RETURNING id::text AS id`, + ); + + await (await configuredService()).sendForNotification({ + id: row.id, + type: "PlayerSanctioned", + }); + + expect(webPush.sendNotification).toHaveBeenCalledTimes(1); + const payload = JSON.parse( + (webPush.sendNotification as jest.Mock).mock.calls[0][1], + ); + // Two visible broadcasts plus one team invite. + expect(payload.unread).toBe(3); + }); + it("drops a row already dealt with in the bell", async () => { const steamId = await fx.player(); await subscribe(steamId); From ced4e49ea1e89c4a1633a88048e82eb1b19d100d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 16:18:52 -0400 Subject: [PATCH 10/10] wip --- .../1879000002000_custom_modes_unranked_backfill/down.sql | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/down.sql diff --git a/hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/down.sql b/hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/down.sql deleted file mode 100644 index 46c5237b..00000000 --- a/hasura/migrations/default/1879000002000_custom_modes_unranked_backfill/down.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Which of these counted before is no longer recorded anywhere; the previous --- rule is not restored. -SELECT 1;