Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/events-contract.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/modules/events/event-names.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const EVENT_NAMES = [
'campaign.message.opened',
'campaign.message.clicked',
'pipeline.stage_changed',
'purchase.approved',
'custom',
] as const;

Expand Down
22 changes: 22 additions & 0 deletions src/modules/events/manifest/event-catalog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
42 changes: 42 additions & 0 deletions src/modules/events/manifest/event-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,47 @@ 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 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',
Expand All @@ -402,6 +443,7 @@ export const EVENT_CATEGORIES: readonly EventCategory[] = [
'conversation',
'message',
'campaign',
'purchase',
'custom',
] as const;

Expand Down
2 changes: 1 addition & 1 deletion src/modules/events/manifest/event-schema.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface EventSchema {
optional: Record<string, FieldSpec>;
}

export type EventCategory = 'contact' | 'conversation' | 'message' | 'campaign' | 'custom';
export type EventCategory = 'contact' | 'conversation' | 'message' | 'campaign' | 'purchase' | 'custom';

export type EventDtoType = 'track' | 'identify';

Expand Down
43 changes: 43 additions & 0 deletions src/modules/events/pipes/event-schema-validation.pipe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { ...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 = {
Expand Down
Loading