fix(api): ensure we always have profile in cache (before inserted to clickhouse)

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-08-13 20:57:42 +02:00
parent 31ccfb8b5f
commit e5cacb73df
5 changed files with 48 additions and 27 deletions

View File

@@ -86,6 +86,10 @@ export default function AddNotificationRule({ rule }: Props) {
}); });
const onSubmit: SubmitHandler<IForm> = (data) => { const onSubmit: SubmitHandler<IForm> = (data) => {
if (!data.config.events[0]?.name) {
toast.error('At least one event is required');
return;
}
mutation.mutate(data); mutation.mutate(data);
}; };
@@ -183,6 +187,10 @@ export default function AddNotificationRule({ rule }: Props) {
<code>{'{{properties.your.property}}'}</code> - Get the value <code>{'{{properties.your.property}}'}</code> - Get the value
of a custom property of a custom property
</li> </li>
<li>
<code>{'{{profile.firstName}}'}</code> - Get the value of a
profile property
</li>
<li> <li>
<div className="flex gap-x-2 flex-wrap"> <div className="flex gap-x-2 flex-wrap">
And many more... And many more...

View File

@@ -3,7 +3,7 @@ import { escape } from 'sqlstring';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { DateTime, toDots } from '@openpanel/common'; import { DateTime, toDots } from '@openpanel/common';
import { cacheable, getCache } from '@openpanel/redis'; import { cacheable } from '@openpanel/redis';
import type { IChartEventFilter } from '@openpanel/validation'; import type { IChartEventFilter } from '@openpanel/validation';
import { botBuffer, eventBuffer, sessionBuffer } from '../buffers'; import { botBuffer, eventBuffer, sessionBuffer } from '../buffers';
@@ -19,7 +19,7 @@ import type { EventMeta, Prisma } from '../prisma-client';
import { db } from '../prisma-client'; import { db } from '../prisma-client';
import { createSqlBuilder } from '../sql-builder'; import { createSqlBuilder } from '../sql-builder';
import { getEventFiltersWhereClause } from './chart.service'; import { getEventFiltersWhereClause } from './chart.service';
import type { IServiceProfile } from './profile.service'; import type { IServiceProfile, IServiceUpsertProfile } from './profile.service';
import { getProfileById, getProfiles, upsertProfile } from './profile.service'; import { getProfileById, getProfiles, upsertProfile } from './profile.service';
export type IImportedEvent = Omit< export type IImportedEvent = Omit<
@@ -325,7 +325,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
await Promise.all([sessionBuffer.add(event), eventBuffer.add(event)]); await Promise.all([sessionBuffer.add(event), eventBuffer.add(event)]);
if (payload.profileId) { if (payload.profileId) {
const profile = { const profile: IServiceUpsertProfile = {
id: String(payload.profileId), id: String(payload.profileId),
isExternal: payload.profileId !== payload.deviceId, isExternal: payload.profileId !== payload.deviceId,
projectId: payload.projectId, projectId: payload.projectId,

View File

@@ -1,7 +1,7 @@
import { omit, uniq } from 'ramda'; import { omit, uniq } from 'ramda';
import { escape } from 'sqlstring'; import { escape } from 'sqlstring';
import { toObject } from '@openpanel/common'; import { strip, toObject } from '@openpanel/common';
import { cacheable } from '@openpanel/redis'; import { cacheable } from '@openpanel/redis';
import type { IChartEventFilter } from '@openpanel/validation'; import type { IChartEventFilter } from '@openpanel/validation';
@@ -69,7 +69,7 @@ export async function getProfileById(id: string, projectId: string) {
return transformProfile(profile); return transformProfile(profile);
} }
export const getProfileByIdCached = getProfileById; //cacheable(getProfileById, 60 * 30); export const getProfileByIdCached = cacheable(getProfileById, 60 * 30);
interface GetProfileListOptions { interface GetProfileListOptions {
projectId: string; projectId: string;
@@ -142,14 +142,15 @@ export async function getProfileListCount({
return data[0]?.count ?? 0; return data[0]?.count ?? 0;
} }
export type IServiceProfile = Omit< export type IServiceProfile = {
IClickhouseProfile, id: string;
'created_at' | 'properties' | 'first_name' | 'last_name' | 'is_external' email: string;
> & { avatar: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
createdAt: Date; createdAt: Date;
isExternal: boolean; isExternal: boolean;
projectId: string;
properties: Record<string, unknown> & { properties: Record<string, unknown> & {
region?: string; region?: string;
country?: string; country?: string;
@@ -197,14 +198,15 @@ export function transformProfile({
...profile ...profile
}: IClickhouseProfile): IServiceProfile { }: IClickhouseProfile): IServiceProfile {
return { return {
...profile,
firstName: first_name, firstName: first_name,
lastName: last_name, lastName: last_name,
isExternal: profile.is_external, isExternal: profile.is_external,
properties: profile.properties properties: toObject(profile.properties),
? omit(['browserVersion', 'osVersion'], toObject(profile.properties))
: {},
createdAt: new Date(created_at), createdAt: new Date(created_at),
projectId: profile.project_id,
id: profile.id,
email: profile.email,
avatar: profile.avatar,
}; };
} }
@@ -221,18 +223,22 @@ export async function upsertProfile(
}: IServiceUpsertProfile, }: IServiceUpsertProfile,
isFromEvent = false, isFromEvent = false,
) { ) {
return profileBuffer.add( const profile: IClickhouseProfile = {
{
id, id,
first_name: firstName!, first_name: firstName || '',
last_name: lastName!, last_name: lastName || '',
email: email!, email: email || '',
avatar: avatar!, avatar: avatar || '',
properties: properties as Record<string, string | undefined>, properties: strip((properties as Record<string, string | undefined>) || {}),
project_id: projectId, project_id: projectId,
created_at: formatClickhouseDate(new Date()), created_at: formatClickhouseDate(new Date()),
is_external: isExternal, is_external: isExternal,
}, };
isFromEvent,
); if (!isFromEvent) {
// Save to cache directly since the profile might be used before its saved in clickhouse
getProfileByIdCached.set(id, projectId)(transformProfile(profile));
}
return profileBuffer.add(profile, isFromEvent);
} }

View File

@@ -87,6 +87,12 @@ export function cacheable<T extends (...args: any) => any>(
const key = getKey(...args); const key = getKey(...args);
return getRedisCache().del(key); return getRedisCache().del(key);
}; };
cachedFn.set =
(...args: Parameters<T>) =>
async (payload: Awaited<ReturnType<T>>) => {
const key = getKey(...args);
return getRedisCache().setex(key, expireInSec, JSON.stringify(payload));
};
return cachedFn; return cachedFn;
} }

View File

@@ -121,6 +121,7 @@ export const notificationRouter = createTRPCRouter({
.map((id) => ({ id })), .map((id) => ({ id })),
}, },
config: input.config, config: input.config,
template: input.template || null,
}, },
}); });
}), }),