From 3c085e445d4b2da8f3c49ece8cefbeaae5ff32ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 7 Jan 2026 11:58:11 +0100 Subject: [PATCH] fix: better validation of events + clean up (#267) --- apps/api/Dockerfile | 3 - apps/api/package.json | 1 - apps/api/src/controllers/event.controller.ts | 4 +- .../api/src/controllers/profile.controller.ts | 12 +- apps/api/src/controllers/track.controller.ts | 455 +++++++++--------- apps/api/src/hooks/client.hook.ts | 7 +- apps/api/src/hooks/duplicate.hook.ts | 7 +- apps/api/src/hooks/is-bot.hook.ts | 14 +- apps/api/src/routes/track.router.ts | 16 - apps/api/src/utils/auth.ts | 5 +- apps/start/Dockerfile | 3 - packages/constants/index.ts | 5 + packages/logger/index.ts | 7 + packages/queue/package.json | 1 - packages/queue/src/queues.ts | 4 +- packages/sdks/sdk/index.ts | 35 -- packages/sdks/sdk/package.json | 3 +- packages/sdks/sdk/src/index.ts | 67 +-- packages/validation/src/index.ts | 1 + packages/validation/src/track.validation.ts | 104 ++++ pnpm-lock.yaml | 9 +- sh/move-sdks-to-examples | 11 - 22 files changed, 387 insertions(+), 387 deletions(-) create mode 100644 packages/validation/src/track.validation.ts delete mode 100755 sh/move-sdks-to-examples diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index a4efad54..a81fab10 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -38,11 +38,9 @@ COPY packages/redis/package.json packages/redis/ COPY packages/logger/package.json packages/logger/ COPY packages/common/package.json packages/common/ COPY packages/payments/package.json packages/payments/ -COPY packages/sdks/sdk/package.json packages/sdks/sdk/ COPY packages/constants/package.json packages/constants/ COPY packages/validation/package.json packages/validation/ COPY packages/integrations/package.json packages/integrations/ -COPY packages/sdks/sdk/package.json packages/sdks/sdk/ COPY patches ./patches # BUILD @@ -107,7 +105,6 @@ COPY --from=build /app/packages/redis ./packages/redis COPY --from=build /app/packages/logger ./packages/logger COPY --from=build /app/packages/common ./packages/common COPY --from=build /app/packages/payments ./packages/payments -COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk COPY --from=build /app/packages/constants ./packages/constants COPY --from=build /app/packages/validation ./packages/validation COPY --from=build /app/packages/integrations ./packages/integrations diff --git a/apps/api/package.json b/apps/api/package.json index 869c81a5..82ad2c65 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -52,7 +52,6 @@ }, "devDependencies": { "@faker-js/faker": "^9.0.1", - "@openpanel/sdk": "workspace:*", "@openpanel/tsconfig": "workspace:*", "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.9", diff --git a/apps/api/src/controllers/event.controller.ts b/apps/api/src/controllers/event.controller.ts index cb9f3597..ba67f119 100644 --- a/apps/api/src/controllers/event.controller.ts +++ b/apps/api/src/controllers/event.controller.ts @@ -3,15 +3,15 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; import { getSalts } from '@openpanel/db'; import { getEventsGroupQueueShard } from '@openpanel/queue'; -import type { PostEventPayload } from '@openpanel/sdk'; import { generateId, slug } from '@openpanel/common'; import { getGeoLocation } from '@openpanel/geo'; +import type { DeprecatedPostEventPayload } from '@openpanel/validation'; import { getStringHeaders, getTimestamp } from './track.controller'; export async function postEvent( request: FastifyRequest<{ - Body: PostEventPayload; + Body: DeprecatedPostEventPayload; }>, reply: FastifyReply, ) { diff --git a/apps/api/src/controllers/profile.controller.ts b/apps/api/src/controllers/profile.controller.ts index 4e86f035..9dd5efd7 100644 --- a/apps/api/src/controllers/profile.controller.ts +++ b/apps/api/src/controllers/profile.controller.ts @@ -5,13 +5,13 @@ import { parseUserAgent } from '@openpanel/common/server'; import { getProfileById, upsertProfile } from '@openpanel/db'; import { getGeoLocation } from '@openpanel/geo'; import type { - IncrementProfilePayload, - UpdateProfilePayload, -} from '@openpanel/sdk'; + DeprecatedIncrementProfilePayload, + DeprecatedUpdateProfilePayload, +} from '@openpanel/validation'; export async function updateProfile( request: FastifyRequest<{ - Body: UpdateProfilePayload; + Body: DeprecatedUpdateProfilePayload; }>, reply: FastifyReply, ) { @@ -52,7 +52,7 @@ export async function updateProfile( export async function incrementProfileProperty( request: FastifyRequest<{ - Body: IncrementProfilePayload; + Body: DeprecatedIncrementProfilePayload; }>, reply: FastifyReply, ) { @@ -94,7 +94,7 @@ export async function incrementProfileProperty( export async function decrementProfileProperty( request: FastifyRequest<{ - Body: IncrementProfilePayload; + Body: DeprecatedIncrementProfilePayload; }>, reply: FastifyReply, ) { diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index 2ee11fc9..bf90ce3d 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -8,13 +8,15 @@ import { getProfileById, getSalts, upsertProfile } from '@openpanel/db'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { getEventsGroupQueueShard } from '@openpanel/queue'; import { getRedisCache } from '@openpanel/redis'; -import type { - DecrementPayload, - IdentifyPayload, - IncrementPayload, - TrackHandlerPayload, - TrackPayload, -} from '@openpanel/sdk'; + +import { + type IDecrementPayload, + type IIdentifyPayload, + type IIncrementPayload, + type ITrackHandlerPayload, + type ITrackPayload, + zTrackHandlerPayload, +} from '@openpanel/validation'; export function getStringHeaders(headers: FastifyRequest['headers']) { return Object.entries( @@ -37,25 +39,28 @@ export function getStringHeaders(headers: FastifyRequest['headers']) { ); } -function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined { - const identity = - 'properties' in body.payload - ? (body.payload?.properties?.__identify as IdentifyPayload | undefined) - : undefined; +function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined { + if (body.type === 'track') { + const identity = body.payload.properties?.__identify as + | IIdentifyPayload + | undefined; - return ( - identity || - (body?.payload?.profileId - ? { - profileId: body.payload.profileId, - } - : undefined) - ); + return ( + identity || + (body.payload.profileId + ? { + profileId: body.payload.profileId, + } + : undefined) + ); + } + + return undefined; } export function getTimestamp( timestamp: FastifyRequest['timestamp'], - payload: TrackHandlerPayload['payload'], + payload: ITrackHandlerPayload['payload'], ) { const safeTimestamp = timestamp || Date.now(); const userDefinedTimestamp = @@ -82,7 +87,7 @@ export function getTimestamp( return { timestamp: safeTimestamp, isTimestampFromThePast: false }; } - // isTimestampFromThePast is true only if timestamp is older than 1 hour + // isTimestampFromThePast is true only if timestamp is older than 15 minutes const isTimestampFromThePast = clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS; @@ -92,170 +97,113 @@ export function getTimestamp( }; } -export async function handler( - request: FastifyRequest<{ - Body: TrackHandlerPayload; - }>, - reply: FastifyReply, -) { - const timestamp = getTimestamp(request.timestamp, request.body.payload); - const ip = - 'properties' in request.body.payload && - request.body.payload.properties?.__ip - ? (request.body.payload.properties.__ip as string) - : request.clientIp; - const ua = request.headers['user-agent']; - const projectId = request.client?.projectId; +interface TrackContext { + projectId: string; + ip: string; + ua?: string; + headers: Record; + timestamp: { value: number; isFromPast: boolean }; + identity?: IIdentifyPayload; + currentDeviceId?: string; + previousDeviceId?: string; + geo: GeoLocation; +} +async function buildContext( + request: FastifyRequest<{ + Body: ITrackHandlerPayload; + }>, + validatedBody: ITrackHandlerPayload, +): Promise { + const projectId = request.client?.projectId; if (!projectId) { - return reply.status(400).send({ - status: 400, - error: 'Bad Request', - message: 'Missing projectId', - }); + throw new HttpError('Missing projectId', { status: 400 }); } - const identity = getIdentity(request.body); + const timestamp = getTimestamp(request.timestamp, validatedBody.payload); + const ip = + validatedBody.type === 'track' && validatedBody.payload.properties?.__ip + ? (validatedBody.payload.properties.__ip as string) + : request.clientIp; + const ua = request.headers['user-agent']; + const headers = getStringHeaders(request.headers); + + const identity = getIdentity(validatedBody); const profileId = identity?.profileId; - const overrideDeviceId = (() => { - const deviceId = - 'properties' in request.body.payload - ? request.body.payload.properties?.__deviceId - : undefined; - if (typeof deviceId === 'string') { - return deviceId; - } - return undefined; - })(); // We might get a profileId from the alias table // If we do, we should use that instead of the one from the payload - if (profileId) { - request.body.payload.profileId = profileId; + if (profileId && validatedBody.type === 'track') { + validatedBody.payload.profileId = profileId; } - switch (request.body.type) { - case 'track': { - const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); - const currentDeviceId = - overrideDeviceId || - (ua - ? generateDeviceId({ - salt: salts.current, - origin: projectId, - ip, - ua, - }) - : ''); - const previousDeviceId = ua + // Get geo location (needed for track and identify) + const geo = await getGeoLocation(ip); + + // Generate device IDs if needed (for track) + let currentDeviceId: string | undefined; + let previousDeviceId: string | undefined; + + if (validatedBody.type === 'track') { + const overrideDeviceId = + typeof validatedBody.payload.properties?.__deviceId === 'string' + ? validatedBody.payload.properties.__deviceId + : undefined; + + const [salts] = await Promise.all([getSalts()]); + currentDeviceId = + overrideDeviceId || + (ua ? generateDeviceId({ - salt: salts.previous, + salt: salts.current, origin: projectId, ip, ua, }) - : ''; - - const promises = []; - - // If we have more than one property in the identity object, we should identify the user - // Otherwise its only a profileId and we should not identify the user - if (identity && Object.keys(identity).length > 1) { - promises.push( - identify({ - payload: identity, - projectId, - geo, - ua, - }), - ); - } - - promises.push( - track({ - payload: request.body.payload, - currentDeviceId, - previousDeviceId, - projectId, - geo, - headers: getStringHeaders(request.headers), - timestamp: timestamp.timestamp, - isTimestampFromThePast: timestamp.isTimestampFromThePast, - }), - ); - - await Promise.all(promises); - break; - } - case 'identify': { - const payload = request.body.payload; - const geo = await getGeoLocation(ip); - if (!payload.profileId) { - throw new HttpError('Missing profileId', { - status: 400, - }); - } - - await identify({ - payload, - projectId, - geo, - ua, - }); - break; - } - case 'alias': { - return reply.status(400).send({ - status: 400, - error: 'Bad Request', - message: 'Alias is not supported', - }); - } - case 'increment': { - await increment({ - payload: request.body.payload, - projectId, - }); - break; - } - case 'decrement': { - await decrement({ - payload: request.body.payload, - projectId, - }); - break; - } - default: { - return reply.status(400).send({ - status: 400, - error: 'Bad Request', - message: 'Invalid type', - }); - } + : ''); + previousDeviceId = ua + ? generateDeviceId({ + salt: salts.previous, + origin: projectId, + ip, + ua, + }) + : ''; } - reply.status(200).send(); + return { + projectId, + ip, + ua, + headers, + timestamp: { + value: timestamp.timestamp, + isFromPast: timestamp.isTimestampFromThePast, + }, + identity, + currentDeviceId, + previousDeviceId, + geo, + }; } -async function track({ - payload, - currentDeviceId, - previousDeviceId, - projectId, - geo, - headers, - timestamp, - isTimestampFromThePast, -}: { - payload: TrackPayload; - currentDeviceId: string; - previousDeviceId: string; - projectId: string; - geo: GeoLocation; - headers: Record; - timestamp: number; - isTimestampFromThePast: boolean; -}) { +async function handleTrack( + payload: ITrackPayload, + context: TrackContext, +): Promise { + const { + projectId, + currentDeviceId, + previousDeviceId, + geo, + headers, + timestamp, + } = context; + + if (!currentDeviceId || !previousDeviceId) { + throw new HttpError('Device ID generation failed', { status: 500 }); + } + const uaInfo = parseUserAgent(headers['user-agent'], payload.properties); const groupId = uaInfo.isServer ? payload.profileId @@ -264,44 +212,51 @@ async function track({ : currentDeviceId; const jobId = [ slug(payload.name), - timestamp, + timestamp.value, projectId, currentDeviceId, groupId, ] .filter(Boolean) .join('-'); - await getEventsGroupQueueShard(groupId).add({ - orderMs: timestamp, - data: { - projectId, - headers, - event: { - ...payload, - timestamp, - isTimestampFromThePast, + + const promises = []; + + // If we have more than one property in the identity object, we should identify the user + // Otherwise its only a profileId and we should not identify the user + if (context.identity && Object.keys(context.identity).length > 1) { + promises.push(handleIdentify(context.identity, context)); + } + + promises.push( + getEventsGroupQueueShard(groupId).add({ + orderMs: timestamp.value, + data: { + projectId, + headers, + event: { + ...payload, + timestamp: timestamp.value, + isTimestampFromThePast: timestamp.isFromPast, + }, + uaInfo, + geo, + currentDeviceId, + previousDeviceId, }, - uaInfo, - geo, - currentDeviceId, - previousDeviceId, - }, - groupId, - jobId, - }); + groupId, + jobId, + }), + ); + + await Promise.all(promises); } -async function identify({ - payload, - projectId, - geo, - ua, -}: { - payload: IdentifyPayload; - projectId: string; - geo: GeoLocation; - ua?: string; -}) { +async function handleIdentify( + payload: IIdentifyPayload, + context: TrackContext, +): Promise { + const { projectId, geo, ua } = context; const uaInfo = parseUserAgent(ua, payload.properties); await upsertProfile({ ...payload, @@ -326,17 +281,15 @@ async function identify({ }); } -async function increment({ - payload, - projectId, -}: { - payload: IncrementPayload; - projectId: string; -}) { +async function adjustProfileProperty( + payload: IIncrementPayload | IDecrementPayload, + projectId: string, + direction: 1 | -1, +): Promise { const { profileId, property, value } = payload; const profile = await getProfileById(profileId, projectId); if (!profile) { - throw new Error('Not found'); + throw new HttpError('Profile not found', { status: 404 }); } const parsed = Number.parseInt( @@ -345,12 +298,12 @@ async function increment({ ); if (Number.isNaN(parsed)) { - throw new Error('Not number'); + throw new HttpError('Property value is not a number', { status: 400 }); } profile.properties = assocPath( property.split('.'), - parsed + (value || 1), + parsed + direction * (value || 1), profile.properties, ); @@ -362,40 +315,74 @@ async function increment({ }); } -async function decrement({ - payload, - projectId, -}: { - payload: DecrementPayload; - projectId: string; -}) { - const { profileId, property, value } = payload; - const profile = await getProfileById(profileId, projectId); - if (!profile) { - throw new Error('Not found'); +async function handleIncrement( + payload: IIncrementPayload, + context: TrackContext, +): Promise { + await adjustProfileProperty(payload, context.projectId, 1); +} + +async function handleDecrement( + payload: IDecrementPayload, + context: TrackContext, +): Promise { + await adjustProfileProperty(payload, context.projectId, -1); +} + +export async function handler( + request: FastifyRequest<{ + Body: ITrackHandlerPayload; + }>, + reply: FastifyReply, +) { + // Validate request body with Zod + const validationResult = zTrackHandlerPayload.safeParse(request.body); + if (!validationResult.success) { + return reply.status(400).send({ + status: 400, + error: 'Bad Request', + message: 'Validation failed', + errors: validationResult.error.errors, + }); } - const parsed = Number.parseInt( - pathOr('0', property.split('.'), profile.properties), - 10, - ); + const validatedBody = validationResult.data; - if (Number.isNaN(parsed)) { - throw new Error('Not number'); + // Handle alias (not supported) + if (validatedBody.type === 'alias') { + return reply.status(400).send({ + status: 400, + error: 'Bad Request', + message: 'Alias is not supported', + }); } - profile.properties = assocPath( - property.split('.'), - parsed - (value || 1), - profile.properties, - ); + // Build request context + const context = await buildContext(request, validatedBody); - await upsertProfile({ - id: profile.id, - projectId, - properties: profile.properties, - isExternal: true, - }); + // Dispatch to appropriate handler + switch (validatedBody.type) { + case 'track': + await handleTrack(validatedBody.payload, context); + break; + case 'identify': + await handleIdentify(validatedBody.payload, context); + break; + case 'increment': + await handleIncrement(validatedBody.payload, context); + break; + case 'decrement': + await handleDecrement(validatedBody.payload, context); + break; + default: + return reply.status(400).send({ + status: 400, + error: 'Bad Request', + message: 'Invalid type', + }); + } + + reply.status(200).send(); } export async function fetchDeviceId( diff --git a/apps/api/src/hooks/client.hook.ts b/apps/api/src/hooks/client.hook.ts index 42aaac98..a58db7b3 100644 --- a/apps/api/src/hooks/client.hook.ts +++ b/apps/api/src/hooks/client.hook.ts @@ -1,10 +1,13 @@ import { SdkAuthError, validateSdkRequest } from '@/utils/auth'; -import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk'; +import type { + DeprecatedPostEventPayload, + ITrackHandlerPayload, +} from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; export async function clientHook( req: FastifyRequest<{ - Body: PostEventPayload | TrackHandlerPayload; + Body: ITrackHandlerPayload | DeprecatedPostEventPayload; }>, reply: FastifyReply, ) { diff --git a/apps/api/src/hooks/duplicate.hook.ts b/apps/api/src/hooks/duplicate.hook.ts index 3fc0689a..b22b807d 100644 --- a/apps/api/src/hooks/duplicate.hook.ts +++ b/apps/api/src/hooks/duplicate.hook.ts @@ -1,10 +1,13 @@ import { isDuplicatedEvent } from '@/utils/deduplicate'; -import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk'; +import type { + DeprecatedPostEventPayload, + ITrackHandlerPayload, +} from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; export async function duplicateHook( req: FastifyRequest<{ - Body: PostEventPayload | TrackHandlerPayload; + Body: ITrackHandlerPayload | DeprecatedPostEventPayload; }>, reply: FastifyReply, ) { diff --git a/apps/api/src/hooks/is-bot.hook.ts b/apps/api/src/hooks/is-bot.hook.ts index 84419b02..d7ae1cd5 100644 --- a/apps/api/src/hooks/is-bot.hook.ts +++ b/apps/api/src/hooks/is-bot.hook.ts @@ -1,17 +1,15 @@ import { isBot } from '@/bots'; import { createBotEvent } from '@openpanel/db'; -import type { TrackHandlerPayload } from '@openpanel/sdk'; -import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { + DeprecatedPostEventPayload, + ITrackHandlerPayload, +} from '@openpanel/validation'; -type DeprecatedEventPayload = { - name: string; - properties: Record; - timestamp: string; -}; +import type { FastifyReply, FastifyRequest } from 'fastify'; export async function isBotHook( req: FastifyRequest<{ - Body: TrackHandlerPayload | DeprecatedEventPayload; + Body: ITrackHandlerPayload | DeprecatedPostEventPayload; }>, reply: FastifyReply, ) { diff --git a/apps/api/src/routes/track.router.ts b/apps/api/src/routes/track.router.ts index ec777a3a..d3fb92c6 100644 --- a/apps/api/src/routes/track.router.ts +++ b/apps/api/src/routes/track.router.ts @@ -14,22 +14,6 @@ const trackRouter: FastifyPluginCallback = async (fastify) => { method: 'POST', url: '/', handler: handler, - schema: { - body: { - type: 'object', - required: ['type', 'payload'], - properties: { - type: { - type: 'string', - enum: ['track', 'increment', 'decrement', 'alias', 'identify'], - }, - payload: { - type: 'object', - additionalProperties: true, - }, - }, - }, - }, }); fastify.route({ diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index 8d4e4332..ef5a8952 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -4,10 +4,11 @@ import { verifyPassword } from '@openpanel/common/server'; import type { IServiceClientWithProject } from '@openpanel/db'; import { ClientType, getClientByIdCached } from '@openpanel/db'; import { getCache } from '@openpanel/redis'; -import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk'; import type { + DeprecatedPostEventPayload, IProjectFilterIp, IProjectFilterProfileId, + ITrackHandlerPayload, } from '@openpanel/validation'; import { path } from 'ramda'; @@ -41,7 +42,7 @@ export class SdkAuthError extends Error { export async function validateSdkRequest( req: FastifyRequest<{ - Body: PostEventPayload | TrackHandlerPayload; + Body: ITrackHandlerPayload | DeprecatedPostEventPayload; }>, ): Promise { const { headers, clientIp } = req; diff --git a/apps/start/Dockerfile b/apps/start/Dockerfile index 1e7eea52..71a3647b 100644 --- a/apps/start/Dockerfile +++ b/apps/start/Dockerfile @@ -34,7 +34,6 @@ COPY packages/payments/package.json packages/payments/ COPY packages/constants/package.json packages/constants/ COPY packages/validation/package.json packages/validation/ COPY packages/integrations/package.json packages/integrations/ -COPY packages/sdks/sdk/package.json packages/sdks/sdk/ COPY packages/sdks/_info/package.json packages/sdks/_info/ COPY patches ./patches # Copy tracking script to self-hosting dashboard @@ -93,7 +92,6 @@ COPY --from=build /app/packages/payments/package.json ./packages/payments/ COPY --from=build /app/packages/constants/package.json ./packages/constants/ COPY --from=build /app/packages/validation/package.json ./packages/validation/ COPY --from=build /app/packages/integrations/package.json ./packages/integrations/ -COPY --from=build /app/packages/sdks/sdk/package.json ./packages/sdks/sdk/ COPY --from=build /app/packages/sdks/_info/package.json ./packages/sdks/_info/ COPY --from=build /app/patches ./patches @@ -132,7 +130,6 @@ COPY --from=build /app/packages/payments ./packages/payments COPY --from=build /app/packages/constants ./packages/constants COPY --from=build /app/packages/validation ./packages/validation COPY --from=build /app/packages/integrations ./packages/integrations -COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk COPY --from=build /app/packages/sdks/_info ./packages/sdks/_info COPY --from=build /app/tooling/typescript ./tooling/typescript diff --git a/packages/constants/index.ts b/packages/constants/index.ts index e644eec7..013b2cfb 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -3,6 +3,11 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns'; export const DEFAULT_ASPECT_RATIO = 0.5625; export const NOT_SET_VALUE = '(not set)'; +export const RESERVED_EVENT_NAMES = [ + 'session_start', + 'session_end', +] as const; + export const timeWindows = { '30min': { key: '30min', diff --git a/packages/logger/index.ts b/packages/logger/index.ts index 753ceb62..6b4d370b 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -8,6 +8,13 @@ export type ILogger = winston.Logger; const logLevel = process.env.LOG_LEVEL ?? 'info'; const silent = process.env.LOG_SILENT === 'true'; +// Add colors for custom levels (fatal, warn, trace) that aren't in default color schemes +winston.addColors({ + fatal: 'red', + warn: 'yellow', + trace: 'gray', +}); + export function createLogger({ name }: { name: string }): ILogger { const service = [process.env.LOG_PREFIX, name, process.env.NODE_ENV ?? 'dev'] .filter(Boolean) diff --git a/packages/queue/package.json b/packages/queue/package.json index a440e7bc..28a28bb3 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -14,7 +14,6 @@ "groupmq": "catalog:" }, "devDependencies": { - "@openpanel/sdk": "workspace:*", "@openpanel/tsconfig": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:" diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index 11526826..19076abc 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -8,8 +8,8 @@ import type { } from '@openpanel/db'; import { createLogger } from '@openpanel/logger'; import { getRedisGroupQueue, getRedisQueue } from '@openpanel/redis'; -import type { TrackPayload } from '@openpanel/sdk'; import { Queue as GroupQueue } from 'groupmq'; +import type { ITrackPayload } from '../../validation'; export const EVENTS_GROUP_QUEUES_SHARDS = Number.parseInt( process.env.EVENTS_GROUP_QUEUES_SHARDS || '1', @@ -32,7 +32,7 @@ export interface EventsQueuePayloadIncomingEvent { type: 'incomingEvent'; payload: { projectId: string; - event: TrackPayload & { + event: ITrackPayload & { timestamp: string | number; isTimestampFromThePast: boolean; }; diff --git a/packages/sdks/sdk/index.ts b/packages/sdks/sdk/index.ts index 7e5b7f5b..cba18435 100644 --- a/packages/sdks/sdk/index.ts +++ b/packages/sdks/sdk/index.ts @@ -1,36 +1 @@ export * from './src/index'; - -// Deprecated types for beta version of the SDKs -// Still used in api/event.controller.ts and api/profile.controller.ts - -export interface OpenpanelEventOptions { - profileId?: string; -} - -export interface PostEventPayload { - name: string; - timestamp: string; - profileId?: string; - properties?: Record & OpenpanelEventOptions; -} - -export interface UpdateProfilePayload { - profileId: string; - firstName?: string; - lastName?: string; - email?: string; - avatar?: string; - properties?: Record; -} - -export interface IncrementProfilePayload { - profileId: string; - property: string; - value: number; -} - -export interface DecrementProfilePayload { - profileId?: string; - property: string; - value: number; -} diff --git a/packages/sdks/sdk/package.json b/packages/sdks/sdk/package.json index a983a73e..cd813c0a 100644 --- a/packages/sdks/sdk/package.json +++ b/packages/sdks/sdk/package.json @@ -9,8 +9,9 @@ "dependencies": {}, "devDependencies": { "@openpanel/tsconfig": "workspace:*", + "@openpanel/validation": "workspace:*", "@types/node": "catalog:", "tsup": "^7.2.0", "typescript": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdks/sdk/src/index.ts b/packages/sdks/sdk/src/index.ts index e3ffe9dd..a7967e78 100644 --- a/packages/sdks/sdk/src/index.ts +++ b/packages/sdks/sdk/src/index.ts @@ -1,31 +1,20 @@ +import type { + IAliasPayload as AliasPayload, + IDecrementPayload as DecrementPayload, + IIdentifyPayload as IdentifyPayload, + IIncrementPayload as IncrementPayload, + ITrackHandlerPayload as TrackHandlerPayload, + ITrackPayload as TrackPayload, +} from '@openpanel/validation'; import { Api } from './api'; -export type TrackHandlerPayload = - | { - type: 'track'; - payload: TrackPayload; - } - | { - type: 'increment'; - payload: IncrementPayload; - } - | { - type: 'decrement'; - payload: DecrementPayload; - } - | { - type: 'alias'; - payload: AliasPayload; - } - | { - type: 'identify'; - payload: IdentifyPayload; - }; - -export type TrackPayload = { - name: string; - properties?: Record; - profileId?: string; +export type { + AliasPayload, + DecrementPayload, + IdentifyPayload, + IncrementPayload, + TrackHandlerPayload, + TrackPayload, }; export type TrackProperties = { @@ -33,32 +22,6 @@ export type TrackProperties = { profileId?: string; }; -export type IdentifyPayload = { - profileId: string; - firstName?: string; - lastName?: string; - email?: string; - avatar?: string; - properties?: Record; -}; - -export type AliasPayload = { - profileId: string; - alias: string; -}; - -export type IncrementPayload = { - profileId: string; - property: string; - value?: number; -}; - -export type DecrementPayload = { - profileId: string; - property: string; - value?: number; -}; - export type OpenPanelOptions = { clientId: string; clientSecret?: string; diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 62162d71..250bf426 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -555,3 +555,4 @@ export const zCreateImport = z.object({ export type ICreateImport = z.infer; export * from './types.insights'; +export * from './track.validation'; diff --git a/packages/validation/src/track.validation.ts b/packages/validation/src/track.validation.ts new file mode 100644 index 00000000..555432eb --- /dev/null +++ b/packages/validation/src/track.validation.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; + +import { RESERVED_EVENT_NAMES } from '@openpanel/constants'; + +export const zTrackPayload = z + .object({ + name: z.string().min(1), + properties: z.record(z.unknown()).optional(), + profileId: z.string().optional(), + }) + .refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), { + message: `Event name cannot be one of the reserved names: ${RESERVED_EVENT_NAMES.join(', ')}`, + path: ['name'], + }); + +export const zIdentifyPayload = z.object({ + profileId: z.string().min(1), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().email().optional(), + avatar: z.string().url().optional(), + properties: z.record(z.unknown()).optional(), +}); + +export const zIncrementPayload = z.object({ + profileId: z.string().min(1), + property: z.string().min(1), + value: z.number().positive().optional(), +}); + +export const zDecrementPayload = z.object({ + profileId: z.string().min(1), + property: z.string().min(1), + value: z.number().positive().optional(), +}); + +export const zAliasPayload = z.object({ + profileId: z.string().min(1), + alias: z.string().min(1), +}); + +export const zTrackHandlerPayload = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('track'), + payload: zTrackPayload, + }), + z.object({ + type: z.literal('identify'), + payload: zIdentifyPayload, + }), + z.object({ + type: z.literal('increment'), + payload: zIncrementPayload, + }), + z.object({ + type: z.literal('decrement'), + payload: zDecrementPayload, + }), + z.object({ + type: z.literal('alias'), + payload: zAliasPayload, + }), +]); + +export type ITrackPayload = z.infer; +export type IIdentifyPayload = z.infer; +export type IIncrementPayload = z.infer; +export type IDecrementPayload = z.infer; +export type IAliasPayload = z.infer; +export type ITrackHandlerPayload = z.infer; + +// Deprecated types for beta version of the SDKs + +export interface DeprecatedOpenpanelEventOptions { + profileId?: string; +} + +export interface DeprecatedPostEventPayload { + name: string; + timestamp: string; + profileId?: string; + properties?: Record & DeprecatedOpenpanelEventOptions; +} + +export interface DeprecatedUpdateProfilePayload { + profileId: string; + firstName?: string; + lastName?: string; + email?: string; + avatar?: string; + properties?: Record; +} + +export interface DeprecatedIncrementProfilePayload { + profileId: string; + property: string; + value: number; +} + +export interface DeprecatedDecrementProfilePayload { + profileId?: string; + property: string; + value: number; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 555616c4..5eb3ddbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,9 +229,6 @@ importers: '@faker-js/faker': specifier: ^9.0.1 version: 9.0.1 - '@openpanel/sdk': - specifier: workspace:* - version: link:../../packages/sdks/sdk '@openpanel/tsconfig': specifier: workspace:* version: link:../../tooling/typescript @@ -1362,9 +1359,6 @@ importers: specifier: 'catalog:' version: 1.1.1-next.2(ioredis@5.8.2) devDependencies: - '@openpanel/sdk': - specifier: workspace:* - version: link:../sdks/sdk '@openpanel/tsconfig': specifier: workspace:* version: link:../../tooling/typescript @@ -1558,6 +1552,9 @@ importers: '@openpanel/tsconfig': specifier: workspace:* version: link:../../../tooling/typescript + '@openpanel/validation': + specifier: workspace:* + version: link:../../validation '@types/node': specifier: 'catalog:' version: 24.7.1 diff --git a/sh/move-sdks-to-examples b/sh/move-sdks-to-examples deleted file mode 100755 index bca1729e..00000000 --- a/sh/move-sdks-to-examples +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# nextjs-13-pages -rm -rf ../openpanel-examples/nextjs-13-pages/node_modules/@openpanel/nextjs && cp -R packages/sdks/nextjs ../openpanel-examples/nextjs-13-pages/node_modules/@openpanel/nextjs -rm -rf ../openpanel-examples/nextjs-13-pages/node_modules/@openpanel/web && cp -R packages/sdks/nextjs ../openpanel-examples/nextjs-13-pages/node_modules/@openpanel/web -# nextjs-app-dir -rm -rf ../openpanel-examples/nextjs-app-dir/node_modules/@openpanel/nextjs && cp -R packages/sdks/nextjs ../openpanel-examples/nextjs-app-dir/node_modules/@openpanel/nextjs -rm -rf ../openpanel-examples/nextjs-app-dir/node_modules/@openpanel/web && cp -R packages/sdks/nextjs ../openpanel-examples/nextjs-app-dir/node_modules/@openpanel/web -# expo-app -rm -rf ../openpanel-examples/expo-app/node_modules/@openpanel/react-native && cp -R packages/sdks/react-native ../openpanel-examples/expo-app/node_modules/@openpanel/react-native -rm -rf ../openpanel-examples/expo-app/node_modules/@openpanel/sdk && cp -R packages/sdks/sdk ../openpanel-examples/expo-app/node_modules/@openpanel/sdk \ No newline at end of file