From ca61c9c24f094822f40200cedec5073ae14082ab Mon Sep 17 00:00:00 2001 From: Kilo Date: Thu, 27 Aug 2026 13:38:20 +0100 Subject: [PATCH] feat(notification): build subscription webhook system with event filtering - Implemented WebhookEventFilterEngine with topic wildcards, attribute comparisons, and logical rule combinations - Added exclusion patterns and payload field projection support - Added developer portal documentation for event filtering in developer-portal/docs/webhook-guide.md - Added comprehensive unit test suite in backend/services/notification/__tests__/webhookFilter.test.ts Closes #955 --- .../__tests__/webhookFilter.test.ts | 207 +++++++++++++ .../notification/webhookFilterEngine.ts | 271 ++++++++++++++++++ backend/services/notification/webhooks.ts | 15 + developer-portal/docs/webhook-guide.md | 47 +++ 4 files changed, 540 insertions(+) create mode 100644 backend/services/notification/__tests__/webhookFilter.test.ts create mode 100644 backend/services/notification/webhookFilterEngine.ts diff --git a/backend/services/notification/__tests__/webhookFilter.test.ts b/backend/services/notification/__tests__/webhookFilter.test.ts new file mode 100644 index 00000000..adc3e05e --- /dev/null +++ b/backend/services/notification/__tests__/webhookFilter.test.ts @@ -0,0 +1,207 @@ +import { + WebhookEventFilterEngine, + webhookFilterEngine, + matchEventPattern, + evaluateAttributeRule, + getNestedProperty, + WebhookFilterConfig, +} from '../webhookFilterEngine'; + +describe('WebhookEventFilterEngine', () => { + let engine: WebhookEventFilterEngine; + + beforeEach(() => { + engine = new WebhookEventFilterEngine(); + }); + + describe('Nested Property Extraction', () => { + it('extracts top-level and nested properties correctly', () => { + const payload = { + id: 'evt_123', + type: 'subscription.created', + data: { + subscription: { + id: 'sub_999', + price: 49.99, + currency: 'USD', + }, + tags: ['enterprise', 'priority'], + }, + }; + + expect(getNestedProperty(payload, 'id')).toBe('evt_123'); + expect(getNestedProperty(payload, 'type')).toBe('subscription.created'); + expect(getNestedProperty(payload, 'data.subscription.price')).toBe(49.99); + expect(getNestedProperty(payload, 'data.subscription.currency')).toBe('USD'); + expect(getNestedProperty(payload, 'data.nonexistent.field')).toBeUndefined(); + expect(getNestedProperty(null, 'id')).toBeUndefined(); + }); + }); + + describe('Wildcard and Pattern Matching', () => { + it('matches exact event types', () => { + expect(matchEventPattern('subscription.created', 'subscription.created')).toBe(true); + expect(matchEventPattern('subscription.created', 'payment.succeeded')).toBe(false); + }); + + it('matches wildcard prefix patterns (* and .*)', () => { + expect(matchEventPattern('*', 'subscription.created')).toBe(true); + expect(matchEventPattern('subscription.*', 'subscription.created')).toBe(true); + expect(matchEventPattern('subscription.*', 'subscription.renewed')).toBe(true); + expect(matchEventPattern('subscription.*', 'payment.succeeded')).toBe(false); + expect(matchEventPattern('payment.*', 'payment.failed')).toBe(true); + }); + + it('matches suffix patterns (*.suffix)', () => { + expect(matchEventPattern('*.created', 'subscription.created')).toBe(true); + expect(matchEventPattern('*.created', 'invoice.created')).toBe(true); + expect(matchEventPattern('*.created', 'subscription.cancelled')).toBe(false); + }); + }); + + describe('Attribute Condition Evaluations', () => { + const payload = { + type: 'payment.succeeded', + data: { + amount: 150, + currency: 'USDC', + customer: { + tier: 'gold', + region: 'NA', + riskScore: 12, + }, + tags: ['web3', 'recurring'], + }, + }; + + it('evaluates comparison operators (eq, neq, gt, gte, lt, lte)', () => { + expect(evaluateAttributeRule(payload, { field: 'data.amount', operator: 'gt', value: 100 })).toBe(true); + expect(evaluateAttributeRule(payload, { field: 'data.amount', operator: 'lt', value: 50 })).toBe(false); + expect(evaluateAttributeRule(payload, { field: 'data.amount', operator: 'gte', value: 150 })).toBe(true); + expect(evaluateAttributeRule(payload, { field: 'data.currency', operator: 'eq', value: 'USDC' })).toBe(true); + expect(evaluateAttributeRule(payload, { field: 'data.currency', operator: 'neq', value: 'EUR' })).toBe(true); + }); + + it('evaluates in and nin operators', () => { + expect(evaluateAttributeRule(payload, { field: 'data.customer.tier', operator: 'in', value: ['gold', 'platinum'] })).toBe(true); + expect(evaluateAttributeRule(payload, { field: 'data.customer.tier', operator: 'in', value: ['silver', 'bronze'] })).toBe(false); + expect(evaluateAttributeRule(payload, { field: 'data.customer.region', operator: 'nin', value: ['EU', 'APAC'] })).toBe(true); + }); + + it('evaluates contains, regex, and exists operators', () => { + expect(evaluateAttributeRule(payload, { field: 'data.tags', operator: 'contains', value: 'web3' })).toBe(true); + expect(evaluateAttributeRule(payload, { field: 'data.currency', operator: 'regex', value: '^USD?C$' })).toBe(true); + expect(evaluateAttributeRule(payload, { field: 'data.customer.riskScore', operator: 'exists', value: true })).toBe(true); + expect(evaluateAttributeRule(payload, { field: 'data.customer.missingField', operator: 'exists', value: false })).toBe(true); + }); + }); + + describe('Complete Webhook Event Filter Evaluation', () => { + const sampleEvent = { + id: 'evt_abc123', + type: 'subscription.created', + data: { + plan: { + id: 'enterprise_tier', + price: 250, + currency: 'USD', + }, + subscriber: { + id: 'user_456', + country: 'US', + }, + }, + }; + + it('accepts event when no filter is provided or filter is disabled', () => { + const result = engine.evaluate(sampleEvent, undefined); + expect(result.isMatch).toBe(true); + + const disabledResult = engine.evaluate(sampleEvent, { enabled: false }); + expect(disabledResult.isMatch).toBe(true); + }); + + it('rejects events matching exclude patterns', () => { + const filter: WebhookFilterConfig = { + enabled: true, + eventPatterns: ['subscription.*'], + excludePatterns: ['subscription.created'], + }; + + const result = engine.evaluate(sampleEvent, filter); + expect(result.isMatch).toBe(false); + expect(result.reason).toContain('exclusion pattern'); + }); + + it('evaluates AND combination rules correctly', () => { + const filter: WebhookFilterConfig = { + enabled: true, + eventPatterns: ['subscription.*'], + ruleCombination: 'AND', + attributeRules: [ + { field: 'data.plan.price', operator: 'gte', value: 200 }, + { field: 'data.plan.currency', operator: 'eq', value: 'USD' }, + ], + }; + + const result = engine.evaluate(sampleEvent, filter); + expect(result.isMatch).toBe(true); + + // Failing rule + const failingFilter: WebhookFilterConfig = { + ...filter, + attributeRules: [ + ...filter.attributeRules!, + { field: 'data.plan.price', operator: 'gt', value: 500 }, + ], + }; + + const failingResult = engine.evaluate(sampleEvent, failingFilter); + expect(failingResult.isMatch).toBe(false); + expect(failingResult.failedRule?.field).toBe('data.plan.price'); + }); + + it('evaluates OR combination rules correctly', () => { + const filter: WebhookFilterConfig = { + enabled: true, + eventPatterns: ['subscription.*'], + ruleCombination: 'OR', + attributeRules: [ + { field: 'data.plan.price', operator: 'gt', value: 1000 }, // Fails + { field: 'data.subscriber.country', operator: 'eq', value: 'US' }, // Passes + ], + }; + + const result = engine.evaluate(sampleEvent, filter); + expect(result.isMatch).toBe(true); + }); + + it('projects specified fields when fieldProjections is configured', () => { + const filter: WebhookFilterConfig = { + enabled: true, + eventPatterns: ['*'], + fieldProjections: ['id', 'type', 'data.plan.price'], + }; + + const result = engine.evaluate(sampleEvent, filter); + expect(result.isMatch).toBe(true); + expect(result.processedPayload).toEqual({ + id: 'evt_abc123', + type: 'subscription.created', + 'data.plan.price': 250, + }); + }); + + it('simulates filter runs for developer portal with execution telemetry', () => { + const filter: WebhookFilterConfig = { + enabled: true, + eventPatterns: ['subscription.created'], + attributeRules: [{ field: 'data.plan.price', operator: 'gt', value: 100 }], + }; + + const simulation = engine.simulate(sampleEvent, filter); + expect(simulation.passed).toBe(true); + expect(simulation.executionTimeMs).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/backend/services/notification/webhookFilterEngine.ts b/backend/services/notification/webhookFilterEngine.ts new file mode 100644 index 00000000..ea1b4375 --- /dev/null +++ b/backend/services/notification/webhookFilterEngine.ts @@ -0,0 +1,271 @@ +/** + * Subscription Webhook Event Filtering Engine — Issue #955 + * + * Provides robust attribute-based and topic-pattern filtering for webhook endpoints, + * allowing developers to subscribe only to relevant event subsets, filter by transaction + * thresholds, currency, plan tier, subscriber metadata, and test filter criteria in the developer portal. + */ + +import type { WebhookEventPayload, WebhookEventType } from '../../../src/types/webhook'; + +export type FilterOperator = + | 'eq' + | 'neq' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'regex' + | 'exists'; + +export interface AttributeRule { + field: string; // e.g. "data.plan.price", "data.subscription.status", "type" + operator: FilterOperator; + value: any; +} + +export interface WebhookFilterConfig { + id?: string; + name?: string; + enabled?: boolean; + eventPatterns?: string[]; // e.g. ["subscription.*", "payment.succeeded", "invoice.*"] + excludePatterns?: string[]; // e.g. ["*.test", "subscription.cancelled"] + attributeRules?: AttributeRule[]; + ruleCombination?: 'AND' | 'OR'; + fieldProjections?: string[]; // If specified, only include these top-level/nested fields +} + +export interface FilterEvaluationResult { + isMatch: boolean; + matchedPattern?: string; + failedRule?: AttributeRule; + reason: string; + processedPayload?: Record; +} + +/** + * Safely extracts a nested property value using dot notation + */ +export function getNestedProperty(obj: any, path: string): any { + if (!obj || typeof obj !== 'object' || !path) return undefined; + const keys = path.split('.'); + let current = obj; + + for (const key of keys) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = current[key]; + } + + return current; +} + +/** + * Evaluates wildcard and glob patterns for event types + * Examples: + * - "subscription.*" matches "subscription.created", "subscription.updated" + * - "payment.*" matches "payment.succeeded" + * - "*" matches all events + */ +export function matchEventPattern(pattern: string, eventType: string): boolean { + if (!pattern || !eventType) return false; + if (pattern === '*' || pattern === eventType) return true; + + if (pattern.endsWith('.*')) { + const prefix = pattern.slice(0, -2); + return eventType.startsWith(prefix + '.') || eventType === prefix; + } + + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(2); + return eventType.endsWith('.' + suffix); + } + + // Regex fallback for advanced glob patterns + try { + const regexPattern = '^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'; + return new RegExp(regexPattern).test(eventType); + } catch { + return pattern === eventType; + } +} + +/** + * Evaluates a single attribute condition against a payload + */ +export function evaluateAttributeRule(payload: Record, rule: AttributeRule): boolean { + const actualValue = getNestedProperty(payload, rule.field); + + switch (rule.operator) { + case 'eq': + return actualValue === rule.value; + case 'neq': + return actualValue !== rule.value; + case 'gt': + return typeof actualValue === 'number' && actualValue > Number(rule.value); + case 'gte': + return typeof actualValue === 'number' && actualValue >= Number(rule.value); + case 'lt': + return typeof actualValue === 'number' && actualValue < Number(rule.value); + case 'lte': + return typeof actualValue === 'number' && actualValue <= Number(rule.value); + case 'in': + return Array.isArray(rule.value) && rule.value.includes(actualValue); + case 'nin': + return Array.isArray(rule.value) && !rule.value.includes(actualValue); + case 'contains': + if (typeof actualValue === 'string') { + return actualValue.includes(String(rule.value)); + } + if (Array.isArray(actualValue)) { + return actualValue.includes(rule.value); + } + return false; + case 'regex': + try { + const re = new RegExp(rule.value); + return re.test(String(actualValue)); + } catch { + return false; + } + case 'exists': + return rule.value ? actualValue !== undefined && actualValue !== null : actualValue === undefined || actualValue === null; + default: + return false; + } +} + +/** + * Core Webhook Event Filter Engine + */ +export class WebhookEventFilterEngine { + /** + * Evaluates if a given webhook event matches the filter configuration + */ + public evaluate( + payload: WebhookEventPayload | Record, + filter?: WebhookFilterConfig + ): FilterEvaluationResult { + // If no filter or filter is disabled, accept all events + if (!filter || filter.enabled === false) { + return { + isMatch: true, + reason: 'No filter applied or filter disabled (accepted all)', + processedPayload: payload, + }; + } + + const eventType = (payload as any).eventType || (payload as any).type || ''; + + // 1. Check excluded patterns first + if (filter.excludePatterns && filter.excludePatterns.length > 0) { + for (const pattern of filter.excludePatterns) { + if (matchEventPattern(pattern, eventType)) { + return { + isMatch: false, + matchedPattern: pattern, + reason: `Event matched exclusion pattern "${pattern}"`, + }; + } + } + } + + // 2. Check event type inclusion patterns + let eventTypeMatched = false; + let matchedPattern: string | undefined; + + if (!filter.eventPatterns || filter.eventPatterns.length === 0 || filter.eventPatterns.includes('*')) { + eventTypeMatched = true; + } else { + for (const pattern of filter.eventPatterns) { + if (matchEventPattern(pattern, eventType)) { + eventTypeMatched = true; + matchedPattern = pattern; + break; + } + } + } + + if (!eventTypeMatched) { + return { + isMatch: false, + reason: `Event type "${eventType}" did not match any of the subscribed patterns`, + }; + } + + // 3. Evaluate attribute rules + const rules = filter.attributeRules || []; + if (rules.length > 0) { + const combination = filter.ruleCombination || 'AND'; + + if (combination === 'AND') { + for (const rule of rules) { + const rulePassed = evaluateAttributeRule(payload, rule); + if (!rulePassed) { + return { + isMatch: false, + failedRule: rule, + reason: `Attribute rule failed for field "${rule.field}" with operator "${rule.operator}"`, + }; + } + } + } else { + // OR combination: at least one rule must pass + const anyPassed = rules.some((rule) => evaluateAttributeRule(payload, rule)); + if (!anyPassed) { + return { + isMatch: false, + reason: 'None of the OR-combined attribute rules matched', + }; + } + } + } + + // 4. Apply optional field projections + let processedPayload = payload; + if (filter.fieldProjections && filter.fieldProjections.length > 0) { + processedPayload = {}; + for (const field of filter.fieldProjections) { + const val = getNestedProperty(payload, field); + if (val !== undefined) { + processedPayload[field] = val; + } + } + } + + return { + isMatch: true, + matchedPattern, + reason: 'Event satisfied all topic and attribute filter requirements', + processedPayload, + }; + } + + /** + * Simulation utility for Developer Portal tester + */ + public simulate( + sampleEvent: Record, + filterConfig: WebhookFilterConfig + ): { + passed: boolean; + result: FilterEvaluationResult; + executionTimeMs: number; + } { + const start = performance?.now ? performance.now() : Date.now(); + const result = this.evaluate(sampleEvent, filterConfig); + const end = performance?.now ? performance.now() : Date.now(); + + return { + passed: result.isMatch, + result, + executionTimeMs: Number((end - start).toFixed(3)), + }; + } +} + +export const webhookFilterEngine = new WebhookEventFilterEngine(); diff --git a/backend/services/notification/webhooks.ts b/backend/services/notification/webhooks.ts index 24c0701e..5a32d4d2 100644 --- a/backend/services/notification/webhooks.ts +++ b/backend/services/notification/webhooks.ts @@ -53,3 +53,18 @@ export type { // ── Event Schema Validator ──────────────────────────────────────────────────── export { EventSchemaValidator, eventSchemaValidator } from '../webhook/eventSchemaValidator'; export type { ValidationResult } from '../webhook/eventSchemaValidator'; + +// ── Webhook Event Filtering Engine (Issue #955) ────────────────────────────── +export { + WebhookEventFilterEngine, + webhookFilterEngine, + matchEventPattern, + evaluateAttributeRule, + getNestedProperty, +} from './webhookFilterEngine'; +export type { + FilterOperator, + AttributeRule, + WebhookFilterConfig, + FilterEvaluationResult, +} from './webhookFilterEngine'; diff --git a/developer-portal/docs/webhook-guide.md b/developer-portal/docs/webhook-guide.md index da50d85c..09a2af29 100644 --- a/developer-portal/docs/webhook-guide.md +++ b/developer-portal/docs/webhook-guide.md @@ -6,6 +6,53 @@ Webhooks allow your application to receive real-time HTTP notifications when eve --- +## Advanced Attribute & Event Filtering + +SubTrackr provides granular filtering so your webhooks only receive relevant events. You can filter by event patterns, exclusion rules, and payload attribute conditions. + +### Filter Configuration Example + +```json +{ + "url": "https://your-app.com/webhooks/subtrackr", + "events": ["subscription.*", "payment.succeeded"], + "filterConfig": { + "enabled": true, + "eventPatterns": ["subscription.*", "payment.succeeded"], + "excludePatterns": ["subscription.cancelled"], + "ruleCombination": "AND", + "attributeRules": [ + { + "field": "data.plan.price", + "operator": "gte", + "value": 100 + }, + { + "field": "data.plan.currency", + "operator": "eq", + "value": "USDC" + } + ], + "fieldProjections": ["id", "type", "occurredAt", "data"] + } +} +``` + +### Supported Filter Operators + +| Operator | Meaning | Example | +|----------|---------|---------| +| | Equals | | +| | Not Equals | | +| / | Greater than / or equal | | +| / | Less than / or equal | | +| / | In array / Not in array | | +| | String substring or array item | | +| | Regular expression pattern | | +| | Field is present / non-null | | + +--- + ## Quick Start ### 1. Register a Webhook Endpoint