fix: funnel notifications

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-27 10:24:45 +01:00
parent 928c44ef6a
commit 9c6c7bb037
2 changed files with 82 additions and 45 deletions

View File

@@ -4,10 +4,10 @@ import { cacheable } from '@openpanel/redis';
import type { IChartEvent, IChartEventFilter } from '@openpanel/validation';
import { pathOr } from 'ramda';
import {
db,
type Integration,
type Notification,
Prisma,
db,
type Prisma,
} from '../prisma-client';
import type {
IServiceCreateEventPayload,
@@ -90,7 +90,7 @@ export const getNotificationRulesByProjectId = cacheable(
},
});
},
60 * 24,
60 * 24
);
function getIntegration(integrationId: string | null) {
@@ -117,12 +117,30 @@ function getIntegration(integrationId: string | null) {
};
}
function stripNullChars<T>(value: T): T {
if (typeof value === 'string') {
return value.split('\u0000').join('') as T;
}
if (value instanceof Date) {
return value;
}
if (Array.isArray(value)) {
return value.map(stripNullChars) as T;
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, stripNullChars(v)])
) as T;
}
return value;
}
export async function createNotification(notification: ICreateNotification) {
const data: Prisma.NotificationUncheckedCreateInput = {
title: notification.title,
message: notification.message,
projectId: notification.projectId,
payload: notification.payload || Prisma.DbNull,
payload: stripNullChars(notification.payload) || undefined,
...getIntegration(notification.integrationId),
notificationRuleId: notification.notificationRuleId,
};
@@ -138,7 +156,7 @@ export async function createNotification(notification: ICreateNotification) {
}
export function triggerNotification(
notification: Prisma.NotificationUncheckedCreateInput,
notification: Prisma.NotificationUncheckedCreateInput
) {
return notificationQueue.add('sendNotification', {
type: 'sendNotification',
@@ -150,12 +168,14 @@ export function triggerNotification(
function matchEventFilters(
payload: IServiceCreateEventPayload,
filters: IChartEventFilter[],
filters: IChartEventFilter[]
) {
return filters.every((filter) => {
const { name, value, operator } = filter;
if (value.length === 0) return true;
if (value.length === 0) {
return true;
}
if (name === 'has_profile') {
if (value.includes('true')) {
@@ -214,7 +234,7 @@ function matchEventFilters(
export function matchEvent(
payload: IServiceCreateEventPayload,
chartEvent: IChartEvent,
chartEvent: IChartEvent
) {
if (payload.name !== chartEvent.name && chartEvent.name !== '*') {
return false;
@@ -234,7 +254,9 @@ function notificationTemplateEvent({
payload: IServiceCreateEventPayload;
rule: INotificationRuleCached;
}) {
if (!rule.template) return `You received a new "${payload.name}" event`;
if (!rule.template) {
return `You received a new "${payload.name}" event`;
}
let template = rule.template
.replaceAll('$EVENT_NAME', payload.name)
.replaceAll('$RULE_NAME', rule.name)
@@ -249,7 +271,7 @@ function notificationTemplateEvent({
if (value) {
template = template.replaceAll(
match,
typeof value === 'object' ? JSON.stringify(value) : value,
typeof value === 'object' ? JSON.stringify(value) : value
);
}
}
@@ -264,14 +286,17 @@ function notificationTemplateFunnel({
events: IServiceEvent[];
rule: INotificationRuleCached;
}) {
if (!rule.template) return `Funnel "${rule.name}" completed`;
if (!rule.template) {
return `Funnel "${rule.name}" completed`;
}
return rule.template
.replaceAll('$EVENT_NAME', events.map((e) => e.name).join(' -> '))
.replaceAll('$RULE_NAME', rule.name);
}
const PROFILE_TEMPLATE_REGEX = /{{profile\.[^}]*}}/;
export async function checkNotificationRulesForEvent(
payload: IServiceCreateEventPayload,
payload: IServiceCreateEventPayload
) {
const project = await getProjectByIdCached(payload.projectId);
const rules = await getNotificationRulesByProjectId(payload.projectId);
@@ -280,7 +305,7 @@ export async function checkNotificationRulesForEvent(
// so we can use it in the template
if (
payload.profileId &&
rules.some((rule) => rule.template?.match(/{{profile\.[^}]*}}/))
rules.some((rule) => rule.template?.match(PROFILE_TEMPLATE_REGEX))
) {
const profile = await getProfileById(payload.profileId, payload.projectId);
if (profile) {
@@ -317,7 +342,7 @@ export async function checkNotificationRulesForEvent(
...notification,
integrationId: integration.id,
notificationRuleId: rule.id,
}),
})
);
if (rule.sendToApp) {
@@ -326,7 +351,7 @@ export async function checkNotificationRulesForEvent(
...notification,
integrationId: APP_NOTIFICATION_INTEGRATION_ID,
notificationRuleId: rule.id,
}),
})
);
}
@@ -336,13 +361,15 @@ export async function checkNotificationRulesForEvent(
...notification,
integrationId: EMAIL_NOTIFICATION_INTEGRATION_ID,
notificationRuleId: rule.id,
}),
})
);
}
return promises;
}
}),
return [];
})
);
}
@@ -358,13 +385,15 @@ export function getFunnelRules(rules: INotificationRuleCached[]) {
}
export async function checkNotificationRulesForSessionEnd(
events: IServiceEvent[],
events: IServiceEvent[]
) {
const sortedEvents = events.sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
);
const projectId = sortedEvents[0]?.projectId;
if (!projectId) return null;
if (!projectId) {
return null;
}
const [project, rules] = await Promise.all([
getProjectByIdCached(projectId),
@@ -380,12 +409,16 @@ export async function checkNotificationRulesForSessionEnd(
if (matchEvent(event, rule.config.events[funnelIndex]!)) {
matchedEvents.push(event);
funnelIndex++;
if (funnelIndex === rule.config.events.length) break;
if (funnelIndex === rule.config.events.length) {
break;
}
}
}
// If funnel not completed, skip this rule
if (funnelIndex < rule.config.events.length) return [];
if (funnelIndex < rule.config.events.length) {
return [];
}
// Create notification object
const notification = {
@@ -405,7 +438,7 @@ export async function checkNotificationRulesForSessionEnd(
...notification,
integrationId: integration.id,
notificationRuleId: rule.id,
}),
})
),
...(rule.sendToApp
? [