diff --git a/.github/workflows/events-contract.yml b/.github/workflows/events-contract.yml new file mode 100644 index 0000000..6f1f1b8 --- /dev/null +++ b/.github/workflows/events-contract.yml @@ -0,0 +1,31 @@ +name: events contract + +# Anti-drift guard for the events contract (CRM-316): the catalog is mirrored by +# the CRM and the frontend, and the schema pipe decides what the CRM may emit. +# Nothing else ran src/modules/events, so a broken mirror only showed up as 400s +# in the producer's Sidekiq log. In-process: no broker, no DB. + +on: + pull_request: + branches: [main, develop] + paths: + - 'src/modules/events/**' + - '.github/workflows/events-contract.yml' + push: + branches: [main, develop] + paths: + - 'src/modules/events/**' + - '.github/workflows/events-contract.yml' + +jobs: + events-contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install dependencies + run: npm ci + - name: Events catalog + schema pipe specs + run: npx jest --runInBand src/modules/events diff --git a/src/modules/events/event-names.enum.ts b/src/modules/events/event-names.enum.ts index f15c183..10a5694 100644 --- a/src/modules/events/event-names.enum.ts +++ b/src/modules/events/event-names.enum.ts @@ -28,6 +28,7 @@ export const EVENT_NAMES = [ 'campaign.message.opened', 'campaign.message.clicked', 'pipeline.stage_changed', + 'purchase.approved', 'custom', ] as const; diff --git a/src/modules/events/manifest/event-catalog.spec.ts b/src/modules/events/manifest/event-catalog.spec.ts index c81e8ca..acd9b52 100644 --- a/src/modules/events/manifest/event-catalog.spec.ts +++ b/src/modules/events/manifest/event-catalog.spec.ts @@ -35,6 +35,28 @@ describe('events manifest catalog', () => { expect(custom?.schema.optional).toEqual({}); }); + // CRM-316: the purchase captured by the CRM webhook is a first-class event, + // in its own category, with the fields a journey trigger / segment filters on. + it('exposes purchase.approved as a track event in the purchase category', () => { + const entry = getEvent('purchase.approved'); + expect(entry).toBeDefined(); + expect(entry?.category).toBe('purchase'); + expect(entry?.dtoType).toBe('track'); + expect(Object.keys(entry!.schema.required).sort()).toEqual([ + 'pipeline_id', + 'pipeline_item_id', + 'provider', + 'purchase_id', + 'source', + ]); + expect(entry?.schema.optional.amount.type).toBe('number'); + expect(entry?.schema.optional.product.type).toBe('string'); + expect(EVENT_CATEGORIES).toContain('purchase'); + expect(getEventsByCategory('purchase').map((e) => e.eventName)).toEqual([ + 'purchase.approved', + ]); + }); + it('returns undefined for an unknown event name', () => { expect(getEvent('not.a.real.event')).toBeUndefined(); }); diff --git a/src/modules/events/manifest/event-catalog.ts b/src/modules/events/manifest/event-catalog.ts index 3805715..f35f5d9 100644 --- a/src/modules/events/manifest/event-catalog.ts +++ b/src/modules/events/manifest/event-catalog.ts @@ -383,6 +383,45 @@ const ENTRIES: EventCatalogEntry[] = [ }, }, }, + // CRM-316: capture of POST /api/v1/webhooks/purchases/:provider, with the + // contact the CRM resolved — a journey starts on "bought X", a segment + // filters "spent more than Y". + { + eventName: 'purchase.approved', + category: 'purchase', + dtoType: 'track', + labelPt: 'Compra aprovada', + labelEn: 'Purchase approved', + description: + 'A purchase was approved on a payment platform and captured as a lead in the CRM.', + schema: { + required: { + provider: f( + 'string', + 'Payment platform key (virtu, hotmart, kiwify, cakto)', + ), + purchase_id: f('string', 'Purchase/order id on the platform'), + pipeline_id: f('uuid'), + pipeline_item_id: f('uuid', 'Card that holds the purchase'), + source: f('string'), + }, + optional: { + product: f('string'), + amount: f( + 'number', + 'Currency major unit (e.g. 197.5 reais), never cents', + ), + currency: f('string'), + platform_event: f('string', 'Event name as the platform sent it'), + outcome: f('string', 'created | already_in_pipeline'), + new_contact: f('boolean', 'Whether the purchase created the contact'), + contact_id: f('uuid'), + pipeline_name: f('string'), + pipeline_stage_id: f('uuid'), + pipeline_stage_name: f('string'), + }, + }, + }, { eventName: 'custom', category: 'custom', @@ -402,6 +441,7 @@ export const EVENT_CATEGORIES: readonly EventCategory[] = [ 'conversation', 'message', 'campaign', + 'purchase', 'custom', ] as const; diff --git a/src/modules/events/manifest/event-schema.types.ts b/src/modules/events/manifest/event-schema.types.ts index 89b8fe3..2fea64d 100644 --- a/src/modules/events/manifest/event-schema.types.ts +++ b/src/modules/events/manifest/event-schema.types.ts @@ -12,7 +12,7 @@ export interface EventSchema { optional: Record; } -export type EventCategory = 'contact' | 'conversation' | 'message' | 'campaign' | 'custom'; +export type EventCategory = 'contact' | 'conversation' | 'message' | 'campaign' | 'purchase' | 'custom'; export type EventDtoType = 'track' | 'identify'; diff --git a/src/modules/events/pipes/event-schema-validation.pipe.spec.ts b/src/modules/events/pipes/event-schema-validation.pipe.spec.ts index a5b03e9..845622a 100644 --- a/src/modules/events/pipes/event-schema-validation.pipe.spec.ts +++ b/src/modules/events/pipes/event-schema-validation.pipe.spec.ts @@ -21,6 +21,49 @@ describe('EventSchemaValidationPipe', () => { expect(pipe.transform(value, bodyMetadata)).toBe(value); }); + // CRM-316: the payload the CRM emits for an approved purchase, as-is. + describe('purchase.approved (CRM-316)', () => { + const purchase = { + messageId: 'purchase.approved:c1:virtu.ord-1', + contactId: '550e8400-e29b-41d4-a716-446655440001', + event: 'purchase.approved', + properties: { + provider: 'virtu', + purchase_id: 'ord-1', + pipeline_id: '550e8400-e29b-41d4-a716-446655440002', + pipeline_item_id: '550e8400-e29b-41d4-a716-446655440003', + source: 'purchase_webhook', + product: 'Curso X', + amount: 297.9, + currency: 'BRL', + outcome: 'created', + new_contact: true, + }, + }; + + it('accepts the CRM payload with a numeric amount', () => { + expect(pipe.transform(purchase, bodyMetadata)).toBe(purchase); + }); + + it('rejects a purchase without purchase_id', () => { + const rest: Record = { ...purchase.properties }; + delete rest.purchase_id; + expect(() => + pipe.transform({ ...purchase, properties: rest }, bodyMetadata), + ).toThrow(BadRequestException); + }); + + it('rejects a string amount (the CRM must send a number)', () => { + const value = { + ...purchase, + properties: { ...purchase.properties, amount: '297.90' }, + }; + expect(() => pipe.transform(value, bodyMetadata)).toThrow( + BadRequestException, + ); + }); + }); + describe('AC3 — required field validation (track path)', () => { it('rejects message.delivered without message_id with MissingRequiredField', () => { const value = {