From 32f474e1c6e8818c3471ea6582e9bc00a9dc7d22 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 16 Jun 2026 22:34:22 -0400 Subject: [PATCH 1/3] Add POST /player/basic/batch for multi-player name resolution. Single round-trip for up to 12 membership IDs using WHERE membership_id = ANY(...). Used by Website fireteam display to avoid N+1 basic calls. Co-authored-by: Cursor --- src/routes/player/basic-batch.test.ts | 51 +++++++++++++++++++++++++++ src/routes/player/basic-batch.ts | 35 ++++++++++++++++++ src/routes/player/index.ts | 2 ++ src/services/player.ts | 22 ++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 src/routes/player/basic-batch.test.ts create mode 100644 src/routes/player/basic-batch.ts diff --git a/src/routes/player/basic-batch.test.ts b/src/routes/player/basic-batch.test.ts new file mode 100644 index 00000000..12e18d6e --- /dev/null +++ b/src/routes/player/basic-batch.test.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" + +import { getFixturePool } from "@/lib/test-fixture-db" +import { expectOk } from "@/lib/test-utils" + +import { playerBasicBatchRoute } from "./basic-batch" + +const fixtureDb = getFixturePool() +const fixtureMembershipId = "4611686019000000402" + +beforeAll(async () => { + await fixtureDb.query(`DELETE FROM core.player_stats WHERE membership_id = $1::bigint`, [ + fixtureMembershipId + ]) + await fixtureDb.query(`DELETE FROM core.player WHERE membership_id = $1::bigint`, [ + fixtureMembershipId + ]) + await fixtureDb.query( + `INSERT INTO core.player ( + membership_id, membership_type, icon_path, display_name, + bungie_global_display_name, bungie_global_display_name_code, last_seen, first_seen, + clears, fresh_clears, sherpas, total_time_played_seconds, sum_of_best, wfr_score, + cheat_level, is_private, is_whitelisted, updated_at + ) VALUES + ($1::bigint, 3, NULL, 'fixture_basic_batch', 'fixture_basic_batch', '0402', NOW(), NOW(), 1, 1, 0, 100, 100, 0, 0, false, false, NOW())`, + [fixtureMembershipId] + ) +}) + +afterAll(async () => { + await fixtureDb.query(`DELETE FROM core.player_stats WHERE membership_id = $1::bigint`, [ + fixtureMembershipId + ]) + await fixtureDb.query(`DELETE FROM core.player WHERE membership_id = $1::bigint`, [ + fixtureMembershipId + ]) +}) + +describe("player basic batch 200", () => { + test("returns found players and omits unknown ids", async () => { + const result = await playerBasicBatchRoute.$mock({ + body: { + membershipIds: [fixtureMembershipId, "1"] + } + }) + + const data = expectOk(result) + expect(data.players).toHaveLength(1) + expect(data.players[0]?.membershipId).toBe(fixtureMembershipId) + }) +}) diff --git a/src/routes/player/basic-batch.ts b/src/routes/player/basic-batch.ts new file mode 100644 index 00000000..f68698ec --- /dev/null +++ b/src/routes/player/basic-batch.ts @@ -0,0 +1,35 @@ +import { RaidHubRoute } from "@/core/RaidHubRoute" +import { playersQueue } from "@/integrations/rabbitmq/queues" +import { cacheControl } from "@/middleware/cache-control" +import { zPlayerInfo } from "@/schema/components/PlayerInfo" +import { zBigIntString } from "@/schema/input" +import { getPlayers } from "@/services/player" +import { z } from "zod" + +export const playerBasicBatchRoute = new RaidHubRoute({ + method: "post", + description: + "Batch variant of `/player/{membershipId}/basic`. Resolves up to 12 players in one round-trip.", + body: z.object({ + membershipIds: z.array(zBigIntString()).min(1).max(12) + }), + middleware: [cacheControl(300)], + response: { + success: { + statusCode: 200, + schema: z.object({ + players: z.array(zPlayerInfo) + }) + }, + errors: [] + }, + async handler(req, after) { + const players = await getPlayers(req.body.membershipIds) + + after(async () => { + await Promise.all(req.body.membershipIds.map(id => playersQueue.send(id))) + }) + + return RaidHubRoute.ok({ players }) + } +}) diff --git a/src/routes/player/index.ts b/src/routes/player/index.ts index 144b9a46..8f2cd80a 100644 --- a/src/routes/player/index.ts +++ b/src/routes/player/index.ts @@ -1,4 +1,5 @@ import { RaidHubRouter } from "@/core/RaidHubRouter" +import { playerBasicBatchRoute } from "./basic-batch" import { playerBasicRoute } from "./membershipId/basic" import { playerHistoryRoute } from "./membershipId/history" import { playerInstancesRoute } from "./membershipId/instances" @@ -9,6 +10,7 @@ import { playerSearchRoute } from "./search" export const playerRouter = new RaidHubRouter({ routes: [ { path: "/search", route: playerSearchRoute }, + { path: "/basic/batch", route: playerBasicBatchRoute }, { path: "/:membershipId", route: new RaidHubRouter({ diff --git a/src/services/player.ts b/src/services/player.ts index 7a7cf524..91485dfe 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -26,6 +26,28 @@ export const getPlayer = async (membershipId: bigint | string) => { { params: [membershipId] } ) } + +export const getPlayers = async (membershipIds: readonly (bigint | string)[]) => { + if (!membershipIds.length) { + return [] + } + + return await pgReader.queryRows( + `SELECT + membership_id AS "membershipId", + membership_type AS "membershipType", + icon_path AS "iconPath", + display_name AS "displayName", + bungie_global_display_name AS "bungieGlobalDisplayName", + bungie_global_display_name_code AS "bungieGlobalDisplayNameCode", + last_seen AS "lastSeen", + is_private AS "isPrivate", + cheat_level AS "cheatLevel" + FROM player + WHERE membership_id = ANY($1::bigint[])`, + { params: [membershipIds] } + ) +} export const getPlayerActivityStats = async (membershipId: bigint | string) => { return await withHistogramTimer( playerProfileQueryTimer, From fd5dadc9e2fd659661218ac53c17a37cbeb2ddab Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 16 Jun 2026 22:44:04 -0400 Subject: [PATCH 2/3] Fix basic-batch test typing and regenerate OpenAPI spec. Co-authored-by: Cursor --- open-api/openapi.json | 150 ++++++++++++++++++++++++++ src/routes/player/basic-batch.test.ts | 8 +- 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/open-api/openapi.json b/open-api/openapi.json index db9ba0aa..ed91c13a 100644 --- a/open-api/openapi.json +++ b/open-api/openapi.json @@ -2428,6 +2428,18 @@ "required": ["params", "results"], "additionalProperties": false }, + "PlayerBasicBatchResponse": { + "type": "object", + "properties": { + "players": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlayerInfo" + } + } + }, + "required": ["players"] + }, "PlayerHistoryResponse": { "type": "object", "properties": { @@ -4169,6 +4181,144 @@ } } }, + "/player/basic/batch": { + "post": { + "description": "Batch variant of `/player/{membershipId}/basic`. Resolves up to 12 players in one round-trip.", + "summary": "/player/basic/batch", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "membershipIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\d+n?$" + }, + "minItems": 1, + "maxItems": 12 + } + }, + "required": ["membershipIds"] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [true] + }, + "response": { + "$ref": "#/components/schemas/PlayerBasicBatchResponse" + } + }, + "required": ["minted", "success", "response"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["BodyValidationError"] + }, + "error": { + "$ref": "#/components/schemas/BodyValidationError" + } + }, + "required": ["minted", "success", "code", "error"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["ApiKeyError"] + }, + "error": { + "$ref": "#/components/schemas/ApiKeyError" + } + }, + "required": ["minted", "success", "code", "error"] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "minted": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean", + "enum": [false] + }, + "code": { + "type": "string", + "enum": ["InternalServerError"] + }, + "error": { + "$ref": "#/components/schemas/InternalServerError" + } + }, + "required": ["minted", "success", "code", "error"] + } + } + } + } + } + } + }, "/player/{membershipId}/history": { "get": { "description": "/activities is deprecated. Use /history now. Get a player's activity history. This endpoint uses date cursors to paginate through a player's activity history. \nThe first request should not include a cursor. Subsequent requests should include the `nextCursor` \nvalue from the previous response. Note that the first request may not return the full number of activities requested\nin order to optimize performance. Subsequent requests will return the full number of activities requested.", diff --git a/src/routes/player/basic-batch.test.ts b/src/routes/player/basic-batch.test.ts index 12e18d6e..a04de654 100644 --- a/src/routes/player/basic-batch.test.ts +++ b/src/routes/player/basic-batch.test.ts @@ -44,8 +44,10 @@ describe("player basic batch 200", () => { } }) - const data = expectOk(result) - expect(data.players).toHaveLength(1) - expect(data.players[0]?.membershipId).toBe(fixtureMembershipId) + expectOk(result) + if (result.type === "ok") { + expect(result.parsed.players).toHaveLength(1) + expect(result.parsed.players[0]?.membershipId).toBe(fixtureMembershipId) + } }) }) From c32abf9fbdeba6b3b1f8ac228b33e69efa0fe341 Mon Sep 17 00:00:00 2001 From: owen Date: Tue, 16 Jun 2026 22:45:34 -0400 Subject: [PATCH 3/3] Compare membershipId as bigint in basic-batch test. Co-authored-by: Cursor --- src/routes/player/basic-batch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/player/basic-batch.test.ts b/src/routes/player/basic-batch.test.ts index a04de654..080db7ec 100644 --- a/src/routes/player/basic-batch.test.ts +++ b/src/routes/player/basic-batch.test.ts @@ -47,7 +47,7 @@ describe("player basic batch 200", () => { expectOk(result) if (result.type === "ok") { expect(result.parsed.players).toHaveLength(1) - expect(result.parsed.players[0]?.membershipId).toBe(fixtureMembershipId) + expect(result.parsed.players[0]?.membershipId).toBe(BigInt(fixtureMembershipId)) } }) })