Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
1afc6bd
fix(EVO-1840): Set Variable node honors Increase/Decrease at runtime
Jul 23, 2026
22fae6c
feat(evo-flow): surface the CRM rejection reason on a 422, not a gene…
nickoliveira23 Jul 24, 2026
0b7e25c
fix(EVO-1840): make Set Variable arithmetic fail visibly instead of w…
gomessguii Jul 24, 2026
51b332f
fix(evo-flow): surface the CRM refusal on the path the pipeline nodes…
gomessguii Jul 24, 2026
6ef6992
style(EVO-1840): trim comments to the decision, drop the review narra…
gomessguii Jul 24, 2026
8154283
Merge pull request #109 from evolution-foundation/fix/EVO-1840-set-va…
gomessguii Jul 24, 2026
93111c5
Merge pull request #111 from evolution-foundation/feat/EVO-2203
gomessguii Jul 25, 2026
e0f3b4f
fix(CRM-209): call the flat message_templates endpoint from the journ…
Aug 20, 2026
4e8d523
fix(segments): escape user-controlled values in ClickHouse SQL builde…
daniloleonecarneiro Aug 20, 2026
1d4fad8
fix(segments): unify custom attribute query generation, fix NotExists…
daniloleonecarneiro Aug 20, 2026
42d53a4
style(segments): trim comments to the non-obvious part (CRM-60)
daniloleonecarneiro Aug 20, 2026
9fe6f83
fix(segments): fail closed on the remaining user-controlled SQL point…
gomessguii Aug 20, 2026
291ee78
Merge pull request #114 from evolution-foundation/danilocarneiro/crm-…
gomessguii Aug 20, 2026
b8e95d3
refactor(CRM-209): review fixes — tighten comments, encode inbox_id, …
gomessguii Aug 20, 2026
eabb7f1
Merge pull request #113 from evolution-foundation/fix/CRM-209-message…
gomessguii Aug 20, 2026
c16aa8d
fix(segments): casa os nomes canônicos de evento de contato no builde…
nickoliveira23 Aug 21, 2026
630507f
fix(segments): exclusão de contato não depende mais do cache de delet…
nickoliveira23 Aug 21, 2026
a2e2028
fix(segments): a otimizacao de deletados preserva o sentinela do ramo…
gomessguii Aug 22, 2026
d342213
fix(segments): condicao de Etiqueta casa por labelId OU labelName (CR…
gomessguii Aug 22, 2026
cd0d45c
refactor(segments): escapa a lista de nomes e nao compartilha o regex…
gomessguii Aug 22, 2026
f1bda76
Merge pull request #115 from evolution-foundation/fix/CRM-215-canonic…
gomessguii Aug 22, 2026
554ff57
fix(journeys): gatilho de webhook casa o evento exato, nao o prefixo …
daniloleonecarneiro Aug 23, 2026
12aa413
fix(journeys): evento sem contato nao inicia execucao de jornada (CRM…
daniloleonecarneiro Aug 23, 2026
791ad6c
refactor(journeys): simplifica o gatilho de webhook e alinha a leitur…
daniloleonecarneiro Aug 23, 2026
4e1a9a9
refactor(journeys): rebaixa o log do descarte e reforca o guard no di…
daniloleonecarneiro Aug 23, 2026
e1591e5
fix(segments): whereProperties precisa ler traits em evento de contat…
Aug 24, 2026
d3be84d
refactor(segments): unifica o filtro de propriedade de evento e alcan…
gomessguii Aug 24, 2026
ec4520d
Merge pull request #118 from evolution-foundation/fix/CRM-241-where-p…
gomessguii Aug 24, 2026
bf8f302
Merge pull request #116 from evolution-foundation/fix/CRM-256-webhook…
gomessguii Aug 24, 2026
dbb45d3
fix(journeys): gatilho de webhook casa so o evento fixo, sem ler conf…
gomessguii Aug 24, 2026
ad772aa
Merge pull request #119 from evolution-foundation/fix/CRM-256-webhook…
gomessguii Aug 24, 2026
2da151d
fix(journeys): torna o descarte de evento sem contato visivel no log …
gomessguii Aug 24, 2026
b4a1ea1
Merge pull request #117 from evolution-foundation/fix/CRM-271-guard-e…
gomessguii Aug 24, 2026
5a0a63c
chore(journeys): remove processWebhookTrigger sem callers (CRM-257)
daniloleonecarneiro Aug 24, 2026
abf80ad
docs(journeys): aponta o caminho de webhook que realmente roda (CRM-257)
daniloleonecarneiro Aug 24, 2026
b7ca55e
Update src/modules/journeys/README.md
daniloleonecarneiro Aug 24, 2026
64a649d
docs(journeys): corrige o verbo do gatilho manual e aponta o contrato…
gomessguii Aug 24, 2026
6d31ce1
Merge pull request #120 from evolution-foundation/chore/CRM-257-remov…
gomessguii Aug 24, 2026
2bbf745
docs(broker): drop the tracker link from the redelivery note
gomessguii Aug 28, 2026
1734849
Merge pull request #121 from evolution-foundation/chore/drop-tracker-…
gomessguii Aug 28, 2026
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
10 changes: 10 additions & 0 deletions docs/journey-manual-trigger.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,13 @@ The emitted event uses `eventName: "webhook.journey_trigger"`,
"processedAt": "2026-06-05T20:36:42.898Z"
}
```

## Why the Webhook trigger node never matches on the event bus

`WebhookTrigger` (the handler behind the Webhook trigger node) matches the exact event name `webhook.journey_trigger`, not the `webhook.` prefix — the e-mail deliverability pipeline writes every provider callback to `contact_events` as `webhook.<platform>` (`sendgrid`, `resend`, `ses`, ...) and those share the `journey-triggers` bus, so a prefix match started journeys off unrelated traffic.

In practice the handler matches nothing: this endpoint bypasses trigger matching (see above), so no producer publishes `webhook.journey_trigger` onto the bus. That is expected — the node still works, because the endpoint starts the named journey directly.

The same handler backs the **Wait for event → Webhook** node: the processor routes wait conditions by their `eventType` and evaluates them with an empty journey. That wait config carries no event name either, so it is satisfied by `webhook.journey_trigger` alone — which means, today, by nothing. Such a session leaves the wait only through its fallback, and an `event` wait has one only when `enableFallback` is set — otherwise it waits indefinitely.

Anything that starts publishing `webhook.journey_trigger` onto the bus must address a single journey itself. The matcher is per-journey and has no journey context to compare against, so an unaddressed event would start **every** journey holding a Webhook trigger.
13 changes: 13 additions & 0 deletions src/modules/events/constants/routing-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,16 @@ describe('EventRoutingConfigHelper.getEventClassification — custom attribute (
).toBe(EventClassification.LIFECYCLE);
});
});

// CRM-215: same defect for the deletion event — the CRM emits `contact.deleted`,
// which fell through to the SYSTEM fallback and scored low priority.
describe('EventRoutingConfigHelper.getEventClassification — contact deleted (CRM-215)', () => {
it.each(['contact.deleted', 'contact_deleted'])(
'classifies %s as LIFECYCLE',
(eventName) => {
expect(EventRoutingConfigHelper.getEventClassification(eventName)).toBe(
EventClassification.LIFECYCLE,
);
},
);
});
8 changes: 5 additions & 3 deletions src/modules/events/constants/routing-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ export const LIFECYCLE_EVENTS = {
SEGMENT_ENTERED: 'segment_entered',
SEGMENT_EXITED: 'segment_exited',
CUSTOM_ATTRIBUTE_CHANGED: 'custom_attribute_changed',
// Canonical dotted name actually emitted by the CRM (EVO-1839). Required because
// getEventClassification matches by substring, so the dotted form is not covered
// by the legacy underscore entry above.
// Canonical dotted names actually emitted by the CRM (EVO-1839, CRM-215). Required
// because getEventClassification matches by substring, so the dotted forms are not
// covered by the legacy underscore entries above — without them the event falls back
// to SYSTEM/low priority instead of LIFECYCLE.
CUSTOM_ATTRIBUTE_CHANGED_DOTTED: 'contact.custom_attribute.changed',
CONTACT_DELETED_DOTTED: 'contact.deleted',
} as const;

// System events
Expand Down
38 changes: 38 additions & 0 deletions src/modules/events/events.service.contact-deleted.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { EventsService } from './events.service';
import { CONTACT_DELETED_INGESTED_EVENT } from '../segments/queries/contact-event-names';

/**
* CRM-215 — ingesting a deleted-contact event must tell the deleted-contacts cache to
* drop its snapshot, otherwise the next incremental recompute can evaluate the deletion
* window with a stale empty cache and keep the contact in every segment.
*/
describe('EventsService.identifyEvent (contact deleted signal)', () => {
const processing = {
processEvent: jest
.fn()
.mockResolvedValue({ messageId: 'm1', status: 'ok' }),
};
const emitter = { emit: jest.fn() };
const service = new EventsService(processing as any, emitter as any);

beforeEach(() => emitter.emit.mockClear());

it.each(['contact.deleted', 'contact_deleted'])(
'emits the signal for %s',
async (eventName) => {
await service.identifyEvent({ contactId: 'c-1', eventName } as any);
expect(emitter.emit).toHaveBeenCalledWith(
CONTACT_DELETED_INGESTED_EVENT,
{ contactId: 'c-1' },
);
},
);

it('stays quiet for any other identify event', async () => {
await service.identifyEvent({
contactId: 'c-1',
eventName: 'contact.updated',
} as any);
expect(emitter.emit).not.toHaveBeenCalled();
});
});
20 changes: 19 additions & 1 deletion src/modules/events/events.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
CONTACT_DELETED_INGESTED_EVENT,
DELETED_CONTACT_EVENT_NAMES,
} from '../segments/queries/contact-event-names';
import {
TrackEventDto,
IdentifyEventDto,
Expand All @@ -15,7 +20,10 @@ import { CustomLoggerService } from 'src/common/services/custom-logger.service';
export class EventsService {
private readonly logger = new CustomLoggerService(EventsService.name);

constructor(private processingService: ProcessingService) {}
constructor(
private processingService: ProcessingService,
private readonly eventEmitter: EventEmitter2,
) {}

async trackEvent(
dto: TrackEventDto,
Expand Down Expand Up @@ -79,6 +87,16 @@ export class EventsService {
throw new BadRequestException(result.error);
}

if (
(DELETED_CONTACT_EVENT_NAMES as readonly string[]).includes(
eventData.eventName ?? '',
)
) {
this.eventEmitter.emit(CONTACT_DELETED_INGESTED_EVENT, {
contactId: dto.contactId,
});
}

return { messageId: result.messageId, status: result.status };
}

Expand Down
32 changes: 32 additions & 0 deletions src/modules/journeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,35 @@ access but no Redis access.
Regression guards for these guarantees live in
`src/modules/cache/services/journey-session-cache.service.spec.ts`
(cross-instance sharing, DB-seeding fallback, `getMultiple` In() clause).

## Webhook entry points: which one actually runs

There is exactly one webhook path into a journey from this service:
`POST /api/v1/journeys/trigger/:journeyId` →
`JourneysService.processSpecificJourneyWebhookTrigger`. It requires
`contact_id` in the payload and starts the named journey **directly**: the
`webhook.journey_trigger` event it builds is handed to
`JourneySessionsService.startJourney` as the workflow's trigger payload. It is
never published to the `journey-triggers` bus and never goes through trigger
matching. The full contract — request body, auth, session semantics, and why
the Webhook trigger node consequently matches nothing on the bus — lives in
[`docs/journey-manual-trigger.md`](../../../docs/journey-manual-trigger.md);
keep that file the source of truth rather than restating it here.

`POST /webhooks/*` (the `event-receiver` / `event-process` runners) is the
e-mail deliverability path: detect platform, validate signature, enrich, write
to ClickHouse `contact_events`. It does not create contacts, does not talk to
the CRM, and does not start journeys — but it is not isolated from them.
`events_to_journey_triggers_mv` forwards **every** `contact_events` row to
`journey-triggers`, so provider callbacks do land on the journey bus as
`webhook.<platform>`. Two guards drop them at the far end: the empty
`contact_id` (`JourneyTriggerProcessor.isDispatchable`, CRM-271) and
`WebhookTrigger`'s exact-name match on `webhook.journey_trigger` (CRM-256).
Resolve a real contact for those rows and the name match is the only thing
left standing between deliverability traffic and every journey holding a
Webhook trigger.

This note exists because the module used to carry a `processWebhookTrigger`
method that built a full `webhook.received` event and never published it
anywhere. It was removed; reading it as "webhook ingestion works" cost real
analysis time more than once.
66 changes: 0 additions & 66 deletions src/modules/journeys/journeys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,72 +286,6 @@ export class JourneysService {
return true;
}

async processWebhookTrigger(
payload: any,
headers: any,
): Promise<{ success: boolean; messageId: string; processedAt: Date }> {
const messageId = uuidv4();
const processedAt = new Date();

this.logger.debug('Processing webhook trigger', {
messageId,
payloadKeys: Object.keys(payload || {}),
headersCount: Object.keys(headers || {}).length,
});

try {
const webhookEvent = {
messageId,
eventType: 'webhook',
eventName: 'webhook.received',
contactId: payload.contactId || null,
properties: {
endpoint: '/api/v1/journeys/trigger',
data: payload,
headers: this.sanitizeHeaders(headers),
method: 'POST',
timestamp: processedAt.toISOString(),
},
timestamp: processedAt,
};

this.logger.log('Webhook trigger event created successfully', {
messageId,
contactId: webhookEvent.contactId,
});

return {
success: true,
messageId,
processedAt,
};
} catch (error) {
this.logger.error('Failed to process webhook trigger', {
messageId,
error: error.message,
stack: error.stack,
});

throw new BadRequestException('Failed to process webhook trigger');
}
}

private sanitizeHeaders(headers: any): Record<string, string> {
if (!headers || typeof headers !== 'object') return {};

const sensitiveHeaders = ['authorization', 'cookie', 'x-api-key'];
const sanitized: Record<string, string> = {};

Object.keys(headers).forEach((key) => {
const normalizedKey = key.toLowerCase();
if (!sensitiveHeaders.includes(normalizedKey)) {
sanitized[key] = String(headers[key]);
}
});

return sanitized;
}

async processSpecificJourneyWebhookTrigger(
journeyId: string,
payload: any,
Expand Down
Loading
Loading