diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81da154d8..12f750559 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -115,6 +115,8 @@ jobs: # keeps rendering a value the list no longer offers. None of the three # was gated before — the shape guard shipped with CRM-442 and never ran # here. The bedrock lifecycle spec stays out: it reaches the network. + # - events manifest catalog (CRM-316): mirror of the evo-flow catalog the + # journey trigger and segment editor read; a drift only shows in the UI. # The whole list runs in ~40s and is deterministic. # # Running the whole suite here was tried and reverted: 160 jsdom files in @@ -180,7 +182,8 @@ jobs: src/contexts/chat/ConversationsContext.spec.tsx \ src/components/ai_agents/__tests__/ModelSelector.availableModels.spec.ts \ src/components/ai_agents/__tests__/ModelSelector.retirement.spec.ts \ - src/components/ai_agents/__tests__/ModelSelector.retiredValue.spec.tsx + src/components/ai_agents/__tests__/ModelSelector.retiredValue.spec.tsx \ + src/lib/events-manifest/catalog.spec.ts # Lint gate scoped to the files the PR touched. eslint: diff --git a/src/components/journey/shared/EventSelector/EventSelector.tsx b/src/components/journey/shared/EventSelector/EventSelector.tsx index 8da9e461f..130002747 100644 --- a/src/components/journey/shared/EventSelector/EventSelector.tsx +++ b/src/components/journey/shared/EventSelector/EventSelector.tsx @@ -6,6 +6,7 @@ import { MessageCircle, MessageSquare, Megaphone, + ShoppingCart, Sparkles, type LucideIcon, } from 'lucide-react'; @@ -62,6 +63,7 @@ const CATEGORY_ICON: Record = { conversation: MessageCircle, message: MessageSquare, campaign: Megaphone, + purchase: ShoppingCart, custom: Sparkles, }; diff --git a/src/components/segments/SegmentConditionEditor.tsx b/src/components/segments/SegmentConditionEditor.tsx index a4d0b7b7a..4d45bf381 100644 --- a/src/components/segments/SegmentConditionEditor.tsx +++ b/src/components/segments/SegmentConditionEditor.tsx @@ -56,6 +56,7 @@ const SEGMENT_EVENT_CATEGORY_LABELS: Record = { conversation: 'Eventos de Conversa', message: 'Eventos de Mensagem', campaign: 'Eventos de Campanha', + purchase: 'Eventos de Compra', custom: 'Personalizado', }; diff --git a/src/i18n/locales/en/events.json b/src/i18n/locales/en/events.json index 990937cdf..57303edd4 100644 --- a/src/i18n/locales/en/events.json +++ b/src/i18n/locales/en/events.json @@ -11,6 +11,7 @@ "conversation": "Conversation events", "message": "Message events", "campaign": "Campaign events", + "purchase": "Purchase events", "custom": "Custom" }, "propertiesForm": { diff --git a/src/i18n/locales/es/events.json b/src/i18n/locales/es/events.json index 55bd0ffe6..fb5b10f9c 100644 --- a/src/i18n/locales/es/events.json +++ b/src/i18n/locales/es/events.json @@ -11,6 +11,7 @@ "conversation": "Eventos de Conversación", "message": "Eventos de Mensaje", "campaign": "Eventos de Campaña", + "purchase": "Eventos de compra", "custom": "Personalizado" }, "propertiesForm": { diff --git a/src/i18n/locales/fr/events.json b/src/i18n/locales/fr/events.json index 5f8b2e5b4..61b755cb4 100644 --- a/src/i18n/locales/fr/events.json +++ b/src/i18n/locales/fr/events.json @@ -11,6 +11,7 @@ "conversation": "Événements de conversation", "message": "Événements de message", "campaign": "Événements de campagne", + "purchase": "Événements d'achat", "custom": "Personnalisé" }, "propertiesForm": { diff --git a/src/i18n/locales/it/events.json b/src/i18n/locales/it/events.json index d6101d690..bdf4eea02 100644 --- a/src/i18n/locales/it/events.json +++ b/src/i18n/locales/it/events.json @@ -11,6 +11,7 @@ "conversation": "Eventi di conversazione", "message": "Eventi di messaggio", "campaign": "Eventi di campagna", + "purchase": "Eventi di acquisto", "custom": "Personalizzato" }, "propertiesForm": { diff --git a/src/i18n/locales/pt-BR/events.json b/src/i18n/locales/pt-BR/events.json index 3dbaa31b8..79c467609 100644 --- a/src/i18n/locales/pt-BR/events.json +++ b/src/i18n/locales/pt-BR/events.json @@ -11,6 +11,7 @@ "conversation": "Eventos de Conversa", "message": "Eventos de Mensagem", "campaign": "Eventos de Campanha", + "purchase": "Eventos de Compra", "custom": "Personalizado" }, "propertiesForm": { diff --git a/src/i18n/locales/pt/events.json b/src/i18n/locales/pt/events.json index 3dbaa31b8..79c467609 100644 --- a/src/i18n/locales/pt/events.json +++ b/src/i18n/locales/pt/events.json @@ -11,6 +11,7 @@ "conversation": "Eventos de Conversa", "message": "Eventos de Mensagem", "campaign": "Eventos de Campanha", + "purchase": "Eventos de Compra", "custom": "Personalizado" }, "propertiesForm": { diff --git a/src/lib/events-manifest/catalog.spec.ts b/src/lib/events-manifest/catalog.spec.ts index 1564702ab..9add84852 100644 --- a/src/lib/events-manifest/catalog.spec.ts +++ b/src/lib/events-manifest/catalog.spec.ts @@ -38,7 +38,7 @@ describe('frontend events manifest mirror', () => { ); }); - it('groups events by category covering all 5 categories', () => { + it('groups events by category covering all 6 categories', () => { const grouped = Object.fromEntries(EVENT_CATEGORIES.map((c) => [c, getEventsByCategory(c)])); expect(grouped.contact.length).toBeGreaterThanOrEqual(6); // EVO-1263: 2 original (created/resolved) + 5 added (activity, first_reply, @@ -46,16 +46,23 @@ describe('frontend events manifest mirror', () => { expect(grouped.conversation).toHaveLength(7); expect(grouped.message.length).toBeGreaterThanOrEqual(4); expect(grouped.campaign.length).toBeGreaterThanOrEqual(4); + // CRM-316: the purchase captured by the CRM webhook, in its own group. + expect(grouped.purchase.map((e) => e.eventName)).toEqual(['purchase.approved']); expect(grouped.custom).toHaveLength(1); }); - // EVO-1263 (AC1): the manifest is a strict replica of the backend SSOT - // EvoFlow::EVENT_NAMES (lib/events/evo_flow_event_names.rb), which has 22 - // canonical names including `custom`. This count is the single guard keeping - // the frontend manifest faithful to the backend enum — keep it strict. - it('mirrors the backend EvoFlow::EVENT_NAMES count exactly (22)', () => { - expect(EVENT_NAMES).toHaveLength(22); - expect(getEventCatalog()).toHaveLength(22); + // EVO-1263 (AC1): this count is the single guard keeping the manifest faithful + // to the backend SSOT EvoFlow::EVENT_NAMES (lib/events/evo_flow_event_names.rb) + // — keep it strict, and bump BACKEND_COUNT with the backend, not with this file. + const BACKEND_COUNT = 24; + // Exposed by the journey builder as its own trigger type, never picked as an event. + const NOT_MIRRORED = ['pipeline.stage_changed']; + + it('mirrors the backend EvoFlow::EVENT_NAMES, minus what is deliberately absent', () => { + const expected = BACKEND_COUNT - NOT_MIRRORED.length; + expect(EVENT_NAMES).toHaveLength(expected); + expect(getEventCatalog()).toHaveLength(expected); + NOT_MIRRORED.forEach((name) => expect(EVENT_NAMES).not.toContain(name)); }); // EVO-1263 (AC1): the 5 conversation events that previously existed only in diff --git a/src/lib/events-manifest/catalog.ts b/src/lib/events-manifest/catalog.ts index 8fd20b54e..4fc7f59da 100644 --- a/src/lib/events-manifest/catalog.ts +++ b/src/lib/events-manifest/catalog.ts @@ -308,6 +308,37 @@ const ENTRIES: EventCatalogEntry[] = [ description: 'User-defined event with free-form key/value properties.', schema: { required: {}, optional: {} }, }, + // CRM-316: purchase webhook capture, with the contact the CRM resolved — a + // journey trigger filters on product/amount, a segment asks "spent > 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'), + }, + }, + }, ]; export const EVENT_CATEGORIES: readonly EventCategory[] = [ @@ -315,6 +346,7 @@ export const EVENT_CATEGORIES: readonly EventCategory[] = [ 'conversation', 'message', 'campaign', + 'purchase', 'custom', ] as const; diff --git a/src/lib/events-manifest/event-names.ts b/src/lib/events-manifest/event-names.ts index abfcac69a..371159f3f 100644 --- a/src/lib/events-manifest/event-names.ts +++ b/src/lib/events-manifest/event-names.ts @@ -20,6 +20,7 @@ export const EVENT_NAMES = [ 'campaign.message.sent', 'campaign.message.opened', 'campaign.message.clicked', + 'purchase.approved', 'custom', ] as const; diff --git a/src/lib/events-manifest/types.ts b/src/lib/events-manifest/types.ts index 93a799713..a576de685 100644 --- a/src/lib/events-manifest/types.ts +++ b/src/lib/events-manifest/types.ts @@ -10,7 +10,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';