Skip to content

Commit 343c37d

Browse files
authored
Pin Services commit and add clan basic endpoint (#123)
* wip * fix typechecl * Delete package-lock.json * fixes * ci: pin RaidHub-Services to 0e6bf77 for schema parity Fix ESLint no-unsafe-call on getInstancePlayerInfo in instances test (avoid .catch(console.error) widening types). Made-with: Cursor * chore: refresh bun.lockb for Bun 1.3.13 (CI frozen install) Made-with: Cursor * style: prettier subscription/auth/pg files; fix unnecessary assertions in tests Made-with: Cursor * fix: allow offset 0 in player search response schema; wire attachUserAuth in admin tests Made-with: Cursor * docs: regenerate openapi.json (search offset schema) Made-with: Cursor * fix: populate auth in RaidHubRoute.$mock from Bearer JWT (matches attachUserAuth) Made-with: Cursor * test: cover authFromHeaders and more discord webhook service paths (CI coverage) Made-with: Cursor * test: fix user-context header array case typing for tsc Made-with: Cursor * test: registerDiscordWebhook happy path with mocked Discord API Made-with: Cursor * test: upsertDiscordWebhook register branch for uncovered channel Made-with: Cursor * fix: align Discord webhook auth contracts and rule reconciliation Address PR review feedback by registering InvalidDiscordAuthError, documenting 401 responses for Discord webhook routes, hardening Discord auth header parsing, adding API key path fallback, and reconciling/de-duping webhook targets with bounded input size. Made-with: Cursor * docs: regenerate openapi after webhook auth error updates Refresh generated OpenAPI output so docs/checks reflect the new Discord webhook error contracts and schema updates. Made-with: Cursor * test: fix discord-context request typing in middleware test Use an explicit unknown-to-request cast in the mock request setup so tsc accepts the test harness while preserving the header-array coverage. Made-with: Cursor * style: apply prettier formatting to discord auth middleware Match repository formatting expectations for discord-context middleware so the format check passes in CI. Made-with: Cursor * fix: address Sentry PR review for Discord webhook persistence Delete the Discord webhook if the Postgres transaction fails after create, since the API cannot run inside a DB transaction. Run destination reactivation and rule updates in a single transaction during upsert. Extract shared update path for reuse. Made-with: Cursor * style: prettier discord-webhooks after Sentry fixes Made-with: Cursor * test: cover updateDiscordWebhook and orphan DELETE failure path Made-with: Cursor * fix: delete prior Discord webhook before register replaces credentials Sentry follow-up: registerDiscordWebhook always created a new channel webhook but could leave the previous Discord webhook orphaned when a row already existed (e.g. direct service calls). Look up stored webhook_id, best-effort DELETE, then create and persist the new webhook. Tests cover the DELETE+POST sequence and queue an extra queryRow for prior-id lookups. Made-with: Cursor * fix(subscriptions): pin services revision and add clan basic lookup route Update CI to use writable Postgres credentials and pin the local services checkout to commit 78c34076bfee4720dd7fb16fdcd6c2d28bb8f7f0. Add /clan/:groupId/basic for lightweight clan identity enrichment used by Discord subscription UX. Made-with: Cursor * docs(openapi): regenerate spec for clan basic endpoint Made-with: Cursor * test(subscriptions): qualify updated_at column in integration join Made-with: Cursor * fix(routes): preserve clan stats path contract Keep /clan/:groupId canonical without a trailing slash and expose /clan/:groupId/basic directly. Also fix Bungie offline route formatting to include the proper query separator in error payloads. Made-with: Cursor
1 parent 767755e commit 343c37d

11 files changed

Lines changed: 577 additions & 11 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
RAIDHUB_SERVICES_REPO=Raid-Hub/RaidHub-Services
2-
RAIDHUB_SERVICES_COMMIT=0e6bf77f6ee4e84c9d0adc6190358b918fdf3626
2+
RAIDHUB_SERVICES_COMMIT=78c34076bfee4720dd7fb16fdcd6c2d28bb8f7f0

.github/workflows/coverage.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ jobs:
3434
JWT_SECRET: "jwt-secret"
3535
POSTGRES_USER: dev
3636
POSTGRES_PASSWORD: password
37+
POSTGRES_WRITABLE_USER: dev
38+
POSTGRES_WRITABLE_PASSWORD: password
3739
CLICKHOUSE_USER: default
3840
CLICKHOUSE_PASSWORD: ""
3941
BUNGIE_API_KEY: ${{ secrets.BUNGIE_API_KEY }}

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ jobs:
4343
JWT_SECRET: "jwt-secret"
4444
POSTGRES_USER: dev
4545
POSTGRES_PASSWORD: password
46+
POSTGRES_WRITABLE_USER: dev
47+
POSTGRES_WRITABLE_PASSWORD: password
4648
CLICKHOUSE_USER: default
4749
CLICKHOUSE_PASSWORD: ""
4850
BUNGIE_API_KEY: ${{ secrets.BUNGIE_API_KEY }}

open-api/openapi.json

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,30 @@
725725
"createdAt"
726726
]
727727
},
728+
"ClanBasic": {
729+
"type": "object",
730+
"properties": {
731+
"groupId": {
732+
"type": "string",
733+
"format": "int64"
734+
},
735+
"name": {
736+
"type": "string"
737+
},
738+
"callSign": {
739+
"type": "string"
740+
},
741+
"motto": {
742+
"type": "string"
743+
},
744+
"avatarPath": {
745+
"type": "string",
746+
"nullable": true
747+
}
748+
},
749+
"required": ["groupId", "name", "callSign", "motto", "avatarPath"],
750+
"additionalProperties": false
751+
},
728752
"ClanBannerData": {
729753
"type": "object",
730754
"properties": {
@@ -3153,6 +3177,30 @@
31533177
},
31543178
"required": ["message", "route"]
31553179
},
3180+
"ClanBasicResponse": {
3181+
"type": "object",
3182+
"properties": {
3183+
"groupId": {
3184+
"type": "string",
3185+
"format": "int64"
3186+
},
3187+
"name": {
3188+
"type": "string"
3189+
},
3190+
"callSign": {
3191+
"type": "string"
3192+
},
3193+
"motto": {
3194+
"type": "string"
3195+
},
3196+
"avatarPath": {
3197+
"type": "string",
3198+
"nullable": true
3199+
}
3200+
},
3201+
"required": ["groupId", "name", "callSign", "motto", "avatarPath"],
3202+
"additionalProperties": false
3203+
},
31563204
"MetricsWeaponsRollingWeekResponse": {
31573205
"type": "object",
31583206
"properties": {
@@ -6818,6 +6866,186 @@
68186866
}
68196867
}
68206868
},
6869+
"/clan/{groupId}/basic": {
6870+
"get": {
6871+
"description": "Low-cost clan identity (name, tag, avatar path) for bots and UIs. Does not load member rosters.",
6872+
"summary": "/clan/{groupId}/basic",
6873+
"parameters": [
6874+
{
6875+
"schema": {
6876+
"type": "string",
6877+
"pattern": "^\\d+n?$"
6878+
},
6879+
"required": true,
6880+
"name": "groupId",
6881+
"in": "path"
6882+
}
6883+
],
6884+
"responses": {
6885+
"200": {
6886+
"description": "Success",
6887+
"content": {
6888+
"application/json": {
6889+
"schema": {
6890+
"type": "object",
6891+
"properties": {
6892+
"minted": {
6893+
"type": "string",
6894+
"format": "date-time"
6895+
},
6896+
"success": {
6897+
"type": "boolean",
6898+
"enum": [true]
6899+
},
6900+
"response": {
6901+
"$ref": "#/components/schemas/ClanBasicResponse"
6902+
}
6903+
},
6904+
"required": ["minted", "success", "response"]
6905+
}
6906+
}
6907+
}
6908+
},
6909+
"401": {
6910+
"description": "Unauthorized",
6911+
"content": {
6912+
"application/json": {
6913+
"schema": {
6914+
"type": "object",
6915+
"properties": {
6916+
"minted": {
6917+
"type": "string",
6918+
"format": "date-time"
6919+
},
6920+
"success": {
6921+
"type": "boolean",
6922+
"enum": [false]
6923+
},
6924+
"code": {
6925+
"type": "string",
6926+
"enum": ["ApiKeyError"]
6927+
},
6928+
"error": {
6929+
"$ref": "#/components/schemas/ApiKeyError"
6930+
}
6931+
},
6932+
"required": ["minted", "success", "code", "error"]
6933+
}
6934+
}
6935+
}
6936+
},
6937+
"404": {
6938+
"description": "Not found",
6939+
"content": {
6940+
"application/json": {
6941+
"schema": {
6942+
"anyOf": [
6943+
{
6944+
"type": "object",
6945+
"properties": {
6946+
"minted": {
6947+
"type": "string",
6948+
"format": "date-time"
6949+
},
6950+
"success": {
6951+
"type": "boolean",
6952+
"enum": [false]
6953+
},
6954+
"code": {
6955+
"type": "string",
6956+
"enum": ["ClanNotFoundError"]
6957+
},
6958+
"error": {
6959+
"$ref": "#/components/schemas/ClanNotFoundError"
6960+
}
6961+
},
6962+
"required": ["minted", "success", "code", "error"]
6963+
},
6964+
{
6965+
"type": "object",
6966+
"properties": {
6967+
"minted": {
6968+
"type": "string",
6969+
"format": "date-time"
6970+
},
6971+
"success": {
6972+
"type": "boolean",
6973+
"enum": [false]
6974+
},
6975+
"code": {
6976+
"type": "string",
6977+
"enum": ["PathValidationError"]
6978+
},
6979+
"error": {
6980+
"$ref": "#/components/schemas/PathValidationError"
6981+
}
6982+
},
6983+
"required": ["minted", "success", "code", "error"]
6984+
}
6985+
]
6986+
}
6987+
}
6988+
}
6989+
},
6990+
"500": {
6991+
"description": "Internal Server Error",
6992+
"content": {
6993+
"application/json": {
6994+
"schema": {
6995+
"type": "object",
6996+
"properties": {
6997+
"minted": {
6998+
"type": "string",
6999+
"format": "date-time"
7000+
},
7001+
"success": {
7002+
"type": "boolean",
7003+
"enum": [false]
7004+
},
7005+
"code": {
7006+
"type": "string",
7007+
"enum": ["InternalServerError"]
7008+
},
7009+
"error": {
7010+
"$ref": "#/components/schemas/InternalServerError"
7011+
}
7012+
},
7013+
"required": ["minted", "success", "code", "error"]
7014+
}
7015+
}
7016+
}
7017+
},
7018+
"503": {
7019+
"description": "BungieServiceOffline",
7020+
"content": {
7021+
"application/json": {
7022+
"schema": {
7023+
"type": "object",
7024+
"properties": {
7025+
"minted": {
7026+
"type": "string",
7027+
"format": "date-time"
7028+
},
7029+
"success": {
7030+
"type": "boolean",
7031+
"enum": [false]
7032+
},
7033+
"code": {
7034+
"type": "string",
7035+
"enum": ["BungieServiceOffline"]
7036+
},
7037+
"error": {
7038+
"$ref": "#/components/schemas/BungieServiceOffline"
7039+
}
7040+
},
7041+
"required": ["minted", "success", "code", "error"]
7042+
}
7043+
}
7044+
}
7045+
}
7046+
}
7047+
}
7048+
},
68217049
"/metrics/weapons/rolling-week": {
68227050
"get": {
68237051
"description": "Wet the top weapons in each slot over the past 168 hours (7 days)",

src/integrations/postgres/index.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,23 @@ configurePostgresParsers()
77
let pgReaderClient: ReturnType<typeof createReader> | null = null
88
let pgAdminClient: ReturnType<typeof createTransactional> | null = null
99

10+
/** Match `getFixturePool()` / libpq defaults so tests and app hit the same host:port. */
11+
const postgresConnectionBase = () => ({
12+
host: process.env.POSTGRES_HOST || "localhost",
13+
port: Number(process.env.POSTGRES_PORT || 5432),
14+
database: "raidhub",
15+
idleTimeoutMillis: 30000,
16+
connectionTimeoutMillis: 10000
17+
})
18+
1019
const getPgReader = () => {
1120
if (!pgReaderClient) {
1221
pgReaderClient = createReader({
22+
...postgresConnectionBase(),
1323
user: process.env.POSTGRES_USER,
1424
password: process.env.POSTGRES_PASSWORD,
15-
database: "raidhub",
1625
min: process.env.PROD ? 5 : 1,
17-
max: process.env.PROD ? 150 : 10,
18-
idleTimeoutMillis: 30000,
19-
connectionTimeoutMillis: 10000
26+
max: process.env.PROD ? 150 : 10
2027
})
2128
}
2229

@@ -26,13 +33,11 @@ const getPgReader = () => {
2633
const getPgAdmin = () => {
2734
if (!pgAdminClient) {
2835
pgAdminClient = createTransactional({
36+
...postgresConnectionBase(),
2937
user: process.env.POSTGRES_WRITABLE_USER,
3038
password: process.env.POSTGRES_WRITABLE_PASSWORD,
31-
database: "raidhub",
3239
min: process.env.PROD ? 2 : 1,
33-
max: process.env.PROD ? 15 : 3,
34-
idleTimeoutMillis: 30000,
35-
connectionTimeoutMillis: 10000
40+
max: process.env.PROD ? 15 : 3
3641
})
3742
}
3843

src/routes/clan/basic.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as bungie from "@/integrations/bungie"
2+
import { expectOk } from "@/lib/test-utils"
3+
import { afterAll, describe, expect, spyOn, test } from "bun:test"
4+
import { clanBasicRoute } from "./basic"
5+
6+
describe("clan basic", () => {
7+
const spyGetClan = spyOn(bungie, "getClan")
8+
9+
afterAll(() => {
10+
spyGetClan.mockRestore()
11+
})
12+
13+
test("returns identity fields", async () => {
14+
spyGetClan.mockResolvedValue({
15+
detail: {
16+
groupId: "49271161",
17+
name: "Example Clan",
18+
groupType: 1,
19+
membershipIdCreated: "1",
20+
creationDate: "",
21+
modificationDate: "",
22+
about: "",
23+
tags: [],
24+
memberCount: 1,
25+
isPublic: true,
26+
isPublicTopicAdminOnly: false,
27+
motto: "We raid",
28+
allowChat: true,
29+
isDefaultPostPublic: true,
30+
chatSecurity: 0,
31+
locale: "en",
32+
avatarImageIndex: 0,
33+
homepage: 0,
34+
membershipOption: 0,
35+
defaultPublicity: 0,
36+
theme: "",
37+
bannerPath: "",
38+
avatarPath: "/img/clan.jpg",
39+
conversationId: "",
40+
enableInvitationMessagingForAdmins: false,
41+
features: {} as never,
42+
clanInfo: {
43+
d2ClanProgressions: {},
44+
clanCallsign: "EXM",
45+
clanBannerData: {} as never
46+
}
47+
}
48+
} as never)
49+
50+
const result = await clanBasicRoute.$mock({ params: { groupId: "49271161" } })
51+
expectOk(result)
52+
if (result.type === "ok") {
53+
expect(result.parsed.name).toBe("Example Clan")
54+
expect(result.parsed.callSign).toBe("EXM")
55+
expect(result.parsed.avatarPath).toBe("/img/clan.jpg")
56+
expect(String(result.parsed.groupId)).toBe("49271161")
57+
}
58+
})
59+
})

0 commit comments

Comments
 (0)