Skip to content

Commit 01ca6be

Browse files
authored
feat(subscriptions): per-target filters on Discord webhook PUT (#125)
* feat(subscriptions): per-target filters on Discord webhook PUT Breaking change: PUT body uses targets.players and targets.clans with optional requireFresh, requireCompleted, and raids per entry. Removes top-level filters and propagateFiltersToAllTargets. Rules are upserted per resolved membership_id / group_id row; omitted targets are removed from the subscription. Made-with: Cursor * style: prettier discord-webhooks.ts Made-with: Cursor * perf(subscriptions): dedupe raid bitmap DB lookups per webhook write Share a resolution cache across player and clan targets so identical raid lists only hit activity_definition once per register/update/upsert. Made-with: Cursor
1 parent 6c713fb commit 01ca6be

5 files changed

Lines changed: 273 additions & 182 deletions

File tree

open-api/openapi.json

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,41 +1805,62 @@
18051805
"minLength": 1,
18061806
"maxLength": 80
18071807
},
1808-
"filters": {
1809-
"type": "object",
1810-
"properties": {
1811-
"requireFresh": {
1812-
"type": "boolean"
1813-
},
1814-
"requireCompleted": {
1815-
"type": "boolean"
1816-
},
1817-
"raids": {
1818-
"type": "array",
1819-
"items": {
1820-
"type": "integer",
1821-
"minimum": 0,
1822-
"exclusiveMinimum": true
1823-
}
1824-
}
1825-
}
1826-
},
18271808
"targets": {
18281809
"type": "object",
18291810
"properties": {
1830-
"playerMembershipIds": {
1811+
"players": {
18311812
"type": "array",
18321813
"items": {
1833-
"type": "string",
1834-
"pattern": "^\\d+$"
1814+
"type": "object",
1815+
"properties": {
1816+
"membershipId": {
1817+
"type": "string",
1818+
"pattern": "^\\d+$"
1819+
},
1820+
"requireFresh": {
1821+
"type": "boolean"
1822+
},
1823+
"requireCompleted": {
1824+
"type": "boolean"
1825+
},
1826+
"raids": {
1827+
"type": "array",
1828+
"items": {
1829+
"type": "integer",
1830+
"minimum": 0,
1831+
"exclusiveMinimum": true
1832+
}
1833+
}
1834+
},
1835+
"required": ["membershipId"]
18351836
},
18361837
"maxItems": 250
18371838
},
1838-
"clanGroupIds": {
1839+
"clans": {
18391840
"type": "array",
18401841
"items": {
1841-
"type": "string",
1842-
"pattern": "^\\d+$"
1842+
"type": "object",
1843+
"properties": {
1844+
"groupId": {
1845+
"type": "string",
1846+
"pattern": "^\\d+$"
1847+
},
1848+
"requireFresh": {
1849+
"type": "boolean"
1850+
},
1851+
"requireCompleted": {
1852+
"type": "boolean"
1853+
},
1854+
"raids": {
1855+
"type": "array",
1856+
"items": {
1857+
"type": "integer",
1858+
"minimum": 0,
1859+
"exclusiveMinimum": true
1860+
}
1861+
}
1862+
},
1863+
"required": ["groupId"]
18431864
},
18441865
"maxItems": 250
18451866
}

src/schema/components/DiscordSubscriptionWebhook.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,34 @@ import { z } from "zod"
44

55
const MAX_DISCORD_WEBHOOK_TARGETS = 250
66

7+
const zDiscordWebhookPlayerTarget = z.object({
8+
membershipId: z.string().regex(/^\d+$/),
9+
requireFresh: z.boolean().optional(),
10+
requireCompleted: z.boolean().optional(),
11+
raids: z.array(zNaturalNumber()).optional()
12+
})
13+
14+
const zDiscordWebhookClanTarget = z.object({
15+
groupId: z.string().regex(/^\d+$/),
16+
requireFresh: z.boolean().optional(),
17+
requireCompleted: z.boolean().optional(),
18+
raids: z.array(zNaturalNumber()).optional()
19+
})
20+
721
export type DiscordWebhookBody = z.input<typeof zDiscordWebhookBody>
822
export const zDiscordWebhookBody = registry.register(
923
"DiscordWebhookBody",
1024
z
1125
.object({
1226
name: z.string().min(1).max(80).optional(),
13-
filters: z
14-
.object({
15-
requireFresh: z.boolean().optional(),
16-
requireCompleted: z.boolean().optional(),
17-
raids: z.array(zNaturalNumber()).optional()
18-
})
19-
.optional(),
2027
targets: z
2128
.object({
22-
playerMembershipIds: z
23-
.array(z.string().regex(/^\d+$/))
29+
players: z
30+
.array(zDiscordWebhookPlayerTarget)
2431
.max(MAX_DISCORD_WEBHOOK_TARGETS)
2532
.optional(),
26-
clanGroupIds: z
27-
.array(z.string().regex(/^\d+$/))
33+
clans: z
34+
.array(zDiscordWebhookClanTarget)
2835
.max(MAX_DISCORD_WEBHOOK_TARGETS)
2936
.optional()
3037
})

src/services/subscriptions/discord-webhooks.integration.test.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe("discord webhook subscriptions service (postgres integration)", () => {
1818
const guildIdA = "999888777666555002"
1919
const guildIdB = "999888777666555003"
2020
const membershipId = "4611686019000990990"
21+
const membershipIdB = "4611686019000990991"
2122

2223
let webhookSeq = 0
2324
const nextWebhookId = () => `wh_int_${Date.now()}_${webhookSeq++}`
@@ -64,12 +65,12 @@ describe("discord webhook subscriptions service (postgres integration)", () => {
6465
await deleteFixtureRows()
6566
})
6667

67-
test("updateDiscordWebhook deactivates all player rules when targets.playerMembershipIds is empty", async () => {
68+
test("updateDiscordWebhook deactivates all player rules when targets.players is empty", async () => {
6869
await seedActiveDestination()
6970

7071
await updateDiscordWebhook(channelId, {
7172
guildId: guildIdA,
72-
targets: { playerMembershipIds: [] }
73+
targets: { players: [] }
7374
})
7475

7576
const row = await fixtureDb.query<{ n: string }>(
@@ -82,13 +83,20 @@ describe("discord webhook subscriptions service (postgres integration)", () => {
8283
expect(row.rows[0].n).toBe("0")
8384
})
8485

85-
test("updateDiscordWebhook persists guild_id and rule updates touch updated_at when column exists", async () => {
86+
test("updateDiscordWebhook persists guild_id and per-player rule filters", async () => {
8687
await seedActiveDestination()
8788

8889
await updateDiscordWebhook(channelId, {
8990
guildId: guildIdB,
90-
filters: { requireFresh: true, requireCompleted: true },
91-
targets: { playerMembershipIds: [membershipId] }
91+
targets: {
92+
players: [
93+
{
94+
membershipId,
95+
requireFresh: true,
96+
requireCompleted: true
97+
}
98+
]
99+
}
92100
})
93101

94102
const cfg = await fixtureDb.query<{ guild_id: string }>(
@@ -114,6 +122,34 @@ describe("discord webhook subscriptions service (postgres integration)", () => {
114122
expect(rule.rows[0].updated_at).not.toBeNull()
115123
})
116124

125+
test("updateDiscordWebhook applies different filters per player in one request", async () => {
126+
await seedActiveDestination()
127+
128+
await updateDiscordWebhook(channelId, {
129+
guildId: guildIdA,
130+
targets: {
131+
players: [
132+
{ membershipId, requireFresh: false, requireCompleted: false },
133+
{ membershipId: membershipIdB, requireFresh: true, requireCompleted: false }
134+
]
135+
}
136+
})
137+
138+
const rows = await fixtureDb.query<{ membership_id: string; require_fresh: boolean }>(
139+
`SELECT membership_id::text AS membership_id, require_fresh
140+
FROM subscriptions.rule r
141+
INNER JOIN subscriptions.discord_destination_config c ON c.destination_id = r.destination_id
142+
WHERE c.channel_id = $1 AND r.scope = 'player' AND r.is_active
143+
ORDER BY membership_id`,
144+
[channelId]
145+
)
146+
expect(rows.rows).toHaveLength(2)
147+
const a = rows.rows.find(r => r.membership_id === membershipId)
148+
const b = rows.rows.find(r => r.membership_id === membershipIdB)
149+
expect(a?.require_fresh).toBe(false)
150+
expect(b?.require_fresh).toBe(true)
151+
})
152+
117153
test("getDiscordWebhookStatus returns active player rules for seeded destination", async () => {
118154
await seedActiveDestination()
119155

@@ -130,8 +166,9 @@ describe("discord webhook subscriptions service (postgres integration)", () => {
130166

131167
await updateDiscordWebhook(channelId, {
132168
guildId: guildIdA,
133-
filters: { raids: [9] },
134-
targets: { playerMembershipIds: [membershipId] }
169+
targets: {
170+
players: [{ membershipId, raids: [9] }]
171+
}
135172
})
136173

137174
const rule = await fixtureDb.query<{ activity_raid_bitmap: string }>(
@@ -158,7 +195,6 @@ describe("discord webhook subscriptions service (postgres integration)", () => {
158195
const out = await upsertDiscordWebhook({
159196
guildId: guildIdB,
160197
channelId,
161-
filters: { requireFresh: false, requireCompleted: false },
162198
targets: {}
163199
})
164200

src/services/subscriptions/discord-webhooks.test.ts

Lines changed: 12 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,7 @@ describe("discord webhook subscriptions service", () => {
7575
queueTransaction([{ destinationId: "42" }, { id: "42" }], [[]])
7676

7777
const result = await updateDiscordWebhook("channel_x", {
78-
guildId: "guild_x",
79-
filters: { requireFresh: true, requireCompleted: false },
80-
targets: {}
78+
guildId: "guild_x"
8179
})
8280

8381
expect(result).toEqual({
@@ -103,9 +101,7 @@ describe("discord webhook subscriptions service", () => {
103101

104102
const result = await upsertDiscordWebhook({
105103
guildId: "guild_1",
106-
channelId: "123456789",
107-
filters: {},
108-
targets: {}
104+
channelId: "123456789"
109105
})
110106

111107
expect(result).toEqual({
@@ -134,9 +130,7 @@ describe("discord webhook subscriptions service", () => {
134130

135131
const result = await upsertDiscordWebhook({
136132
guildId: "guild_2",
137-
channelId: "channel_2",
138-
filters: {},
139-
targets: {}
133+
channelId: "channel_2"
140134
})
141135

142136
expect(result.activated).toBe(true)
@@ -158,10 +152,9 @@ describe("discord webhook subscriptions service", () => {
158152
const result = await upsertDiscordWebhook({
159153
guildId: "guild_1",
160154
channelId: "123456789",
161-
filters: {},
162155
targets: {
163-
playerMembershipIds: [],
164-
clanGroupIds: []
156+
players: [],
157+
clans: []
165158
}
166159
})
167160

@@ -335,9 +328,7 @@ describe("discord webhook subscriptions service", () => {
335328
registerDiscordWebhook({
336329
guildId: "g",
337330
channelId: "c",
338-
name: "Test",
339-
filters: {},
340-
targets: {}
331+
name: "Test"
341332
})
342333
).rejects.toThrow("DISCORD_BOT_TOKEN")
343334
})
@@ -354,9 +345,7 @@ describe("discord webhook subscriptions service", () => {
354345
return expect(
355346
registerDiscordWebhook({
356347
guildId: "g",
357-
channelId: "c",
358-
filters: {},
359-
targets: {}
348+
channelId: "c"
360349
})
361350
).rejects.toThrow("Discord webhook create failed with status 429")
362351
})
@@ -387,9 +376,7 @@ describe("discord webhook subscriptions service", () => {
387376
try {
388377
await registerDiscordWebhook({
389378
guildId: "g",
390-
channelId: "c",
391-
filters: {},
392-
targets: {}
379+
channelId: "c"
393380
})
394381
expect.unreachable("registerDiscordWebhook should have thrown")
395382
} catch (error: unknown) {
@@ -419,9 +406,7 @@ describe("discord webhook subscriptions service", () => {
419406
try {
420407
await registerDiscordWebhook({
421408
guildId: "g",
422-
channelId: "c",
423-
filters: {},
424-
targets: {}
409+
channelId: "c"
425410
})
426411
expect.unreachable("registerDiscordWebhook should have thrown")
427412
} catch (error: unknown) {
@@ -443,9 +428,7 @@ describe("discord webhook subscriptions service", () => {
443428

444429
const result = await upsertDiscordWebhook({
445430
guildId: "guild_u",
446-
channelId: "chan_u",
447-
filters: {},
448-
targets: {}
431+
channelId: "chan_u"
449432
})
450433

451434
expect(result.created).toBe(true)
@@ -481,9 +464,7 @@ describe("discord webhook subscriptions service", () => {
481464

482465
const result = await registerDiscordWebhook({
483466
guildId: "guild_r",
484-
channelId: "chan_r",
485-
filters: {},
486-
targets: {}
467+
channelId: "chan_r"
487468
})
488469

489470
expect(requests.map(r => r.method)).toEqual(["DELETE", "POST"])
@@ -507,9 +488,7 @@ describe("discord webhook subscriptions service", () => {
507488
const result = await registerDiscordWebhook({
508489
guildId: "guild_reg",
509490
channelId: "chan_reg",
510-
name: " CustomName ",
511-
filters: { requireFresh: true, requireCompleted: false },
512-
targets: {}
491+
name: " CustomName "
513492
})
514493

515494
expect(result).toEqual({

0 commit comments

Comments
 (0)