From 121f5e174448f0103694630b26345fd2a31c9a7c Mon Sep 17 00:00:00 2001 From: Nickolas Oliveira Date: Fri, 4 Sep 2026 13:18:24 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(events):=20purchase.approved=20entra?= =?UTF-8?q?=20no=20cat=C3=A1logo=20como=20evento=20de=20primeira=20classe?= =?UTF-8?q?=20(CRM-316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compra aprovada captada pelo webhook de compra do CRM chegava ao evo-flow escondida em campaign.triggered. Com nome e esquema próprios (categoria purchase), uma jornada pode começar em "comprou o produto X" e um segmento pode filtrar "gastou mais de Y" — o EventTrigger e o filtro de propriedades já cobrem por herança. O CRM emite; este é o lado que aceita. Ordem de deploy: evo-flow antes do CRM, senão o worker do CRM recebe 400 e descarta o evento. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JxYjUwr2vV69kYddiXJHRo --- src/modules/events/event-names.enum.ts | 1 + .../events/manifest/event-catalog.spec.ts | 22 ++++++++++ src/modules/events/manifest/event-catalog.ts | 39 +++++++++++++++++ .../events/manifest/event-schema.types.ts | 2 +- .../event-schema-validation.pipe.spec.ts | 43 +++++++++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) 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..1b98c6b 100644 --- a/src/modules/events/manifest/event-catalog.ts +++ b/src/modules/events/manifest/event-catalog.ts @@ -383,6 +383,44 @@ const ENTRIES: EventCatalogEntry[] = [ }, }, }, + // CRM-316: an approved purchase captured by the CRM purchase webhook + // (POST /api/v1/webhooks/purchases/:provider), emitted with the contact the + // CRM resolved. First-class so a journey can start on "bought product X" and + // a segment can filter "spent more than Y" — before this the purchase only + // travelled buried inside campaign.triggered's custom_fields. + { + 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: 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 +440,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 = { From e05c11cc7fe7a32a57eabb35910ad2fbde6fb92f Mon Sep 17 00:00:00 2001 From: Nickolas Oliveira Date: Fri, 4 Sep 2026 14:09:01 -0300 Subject: [PATCH 2/3] =?UTF-8?q?chore(events):=20unidade=20do=20amount=20no?= =?UTF-8?q?=20cat=C3=A1logo=20e=20lane=20de=20CI=20do=20contrato=20de=20ev?= =?UTF-8?q?entos=20(CRM-316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O campo amount do purchase.approved declara a unidade (maior da moeda, nunca centavos), para os produtores convergirem. Nenhuma lane rodava src/modules/events: a events-contract passa a rodar o catálogo e o pipe de validação quando o módulo muda. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JxYjUwr2vV69kYddiXJHRo --- .github/workflows/events-contract.yml | 34 ++++++++++++++++++++ src/modules/events/manifest/event-catalog.ts | 5 ++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/events-contract.yml diff --git a/.github/workflows/events-contract.yml b/.github/workflows/events-contract.yml new file mode 100644 index 0000000..563b27a --- /dev/null +++ b/.github/workflows/events-contract.yml @@ -0,0 +1,34 @@ +name: events contract + +# Anti-drift guard for the events contract (CRM-316). The event catalog +# (event-names enum + manifest) is mirrored by the CRM and by the frontend +# manifest; the schema-validation pipe is what accepts or rejects what the CRM +# emits. Nothing else ran src/modules/events on CI, so a broken mirror only +# showed up as 400s in the producer's Sidekiq log. +# +# Deterministic and fast: everything is 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/manifest/event-catalog.ts b/src/modules/events/manifest/event-catalog.ts index 1b98c6b..abcf9ab 100644 --- a/src/modules/events/manifest/event-catalog.ts +++ b/src/modules/events/manifest/event-catalog.ts @@ -409,7 +409,10 @@ const ENTRIES: EventCatalogEntry[] = [ }, optional: { product: f('string'), - amount: f('number'), + 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'), From 9e8c3ebda012d6bbd010216b51468cbe35fb7957 Mon Sep 17 00:00:00 2001 From: Guilherme Gomes Date: Fri, 4 Sep 2026 14:44:10 -0300 Subject: [PATCH 3/3] =?UTF-8?q?chore(events):=20enxuga=20os=20coment=C3=A1?= =?UTF-8?q?rios=20do=20contrato=20de=20compra=20(CRM-316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cabeçalho da lane e o bloco do catálogo estavam acima do orçamento da casa (~4 linhas em cabeçalho, ~2 inline) e carregavam narrativa que é da PR. --- .github/workflows/events-contract.yml | 11 ++++------- src/modules/events/manifest/event-catalog.ts | 8 +++----- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/events-contract.yml b/.github/workflows/events-contract.yml index 563b27a..6f1f1b8 100644 --- a/.github/workflows/events-contract.yml +++ b/.github/workflows/events-contract.yml @@ -1,12 +1,9 @@ name: events contract -# Anti-drift guard for the events contract (CRM-316). The event catalog -# (event-names enum + manifest) is mirrored by the CRM and by the frontend -# manifest; the schema-validation pipe is what accepts or rejects what the CRM -# emits. Nothing else ran src/modules/events on CI, so a broken mirror only -# showed up as 400s in the producer's Sidekiq log. -# -# Deterministic and fast: everything is in-process (no broker, no DB). +# 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: diff --git a/src/modules/events/manifest/event-catalog.ts b/src/modules/events/manifest/event-catalog.ts index abcf9ab..f35f5d9 100644 --- a/src/modules/events/manifest/event-catalog.ts +++ b/src/modules/events/manifest/event-catalog.ts @@ -383,11 +383,9 @@ const ENTRIES: EventCatalogEntry[] = [ }, }, }, - // CRM-316: an approved purchase captured by the CRM purchase webhook - // (POST /api/v1/webhooks/purchases/:provider), emitted with the contact the - // CRM resolved. First-class so a journey can start on "bought product X" and - // a segment can filter "spent more than Y" — before this the purchase only - // travelled buried inside campaign.triggered's custom_fields. + // 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',