From c853bba175be570495eed6656967632a10531637 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Sun, 18 Jan 2026 23:27:55 +0100 Subject: [PATCH 1/9] feat: profile views --- .infra/crons.ts | 8 ++ ...0115100000_user_profile_analytics.down.sql | 7 + ...260115100000_user_profile_analytics.up.sql | 49 +++++++ src/common/schema/userProfileAnalytics.ts | 13 ++ src/common/user.ts | 24 ++++ src/cron/index.ts | 4 + src/cron/userProfileAnalyticsClickhouse.ts | 118 ++++++++++++++++ .../userProfileAnalyticsHistoryClickhouse.ts | 115 ++++++++++++++++ src/entity/user/UserProfileAnalytics.ts | 32 +++++ .../user/UserProfileAnalyticsHistory.ts | 35 +++++ src/entity/user/index.ts | 2 + src/graphorm/index.ts | 19 +++ .../1768505964487-UserProfileAnalytics.ts | 53 ++++++++ src/schema/users.ts | 127 ++++++++++++++++++ 14 files changed, 606 insertions(+) create mode 100644 clickhouse/migrations/20260115100000_user_profile_analytics.down.sql create mode 100644 clickhouse/migrations/20260115100000_user_profile_analytics.up.sql create mode 100644 src/common/schema/userProfileAnalytics.ts create mode 100644 src/cron/userProfileAnalyticsClickhouse.ts create mode 100644 src/cron/userProfileAnalyticsHistoryClickhouse.ts create mode 100644 src/entity/user/UserProfileAnalytics.ts create mode 100644 src/entity/user/UserProfileAnalyticsHistory.ts create mode 100644 src/migration/1768505964487-UserProfileAnalytics.ts diff --git a/.infra/crons.ts b/.infra/crons.ts index 19691fa32b..6e4d3464f1 100644 --- a/.infra/crons.ts +++ b/.infra/crons.ts @@ -130,6 +130,14 @@ export const crons: Cron[] = [ name: 'post-analytics-history-day-clickhouse', schedule: '3-59/5 * * * *', }, + { + name: 'user-profile-analytics-clickhouse', + schedule: '*/5 * * * *', + }, + { + name: 'user-profile-analytics-history-clickhouse', + schedule: '3-59/5 * * * *', + }, { name: 'clean-zombie-opportunities', schedule: '30 6 * * *', diff --git a/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql b/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql new file mode 100644 index 0000000000..02122a5abd --- /dev/null +++ b/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql @@ -0,0 +1,7 @@ +-- Drop MVs first (they depend on tables) +DROP VIEW IF EXISTS api.user_profile_analytics_history_mv; +DROP VIEW IF EXISTS api.user_profile_analytics_mv; + +-- Drop tables +DROP TABLE IF EXISTS api.user_profile_analytics_history; +DROP TABLE IF EXISTS api.user_profile_analytics; diff --git a/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql b/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql new file mode 100644 index 0000000000..48160f6bbb --- /dev/null +++ b/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql @@ -0,0 +1,49 @@ +-- Main aggregation table for user profile analytics +CREATE TABLE api.user_profile_analytics +( + user_id String, + created_at SimpleAggregateFunction(max, DateTime64(3)), + unique_visitors AggregateFunction(uniq, String) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(created_at) +ORDER BY user_id; + +-- History table for daily aggregates +CREATE TABLE api.user_profile_analytics_history +( + user_id String, + date Date, + created_at SimpleAggregateFunction(max, DateTime64(3)), + unique_visitors AggregateFunction(uniq, String) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(created_at) +ORDER BY (date, user_id); + +-- MV for main aggregation (all-time totals) +CREATE MATERIALIZED VIEW api.user_profile_analytics_mv +TO api.user_profile_analytics +AS +SELECT + target_id AS user_id, + uniqState(user_id) AS unique_visitors, + max(server_timestamp) AS created_at +FROM events.raw_events +WHERE event_name = 'profile view' +AND target_id IS NOT NULL +GROUP BY target_id; + +-- MV for daily history +CREATE MATERIALIZED VIEW api.user_profile_analytics_history_mv +TO api.user_profile_analytics_history +AS +SELECT + target_id AS user_id, + toDate(server_timestamp) AS date, + uniqState(user_id) AS unique_visitors, + max(server_timestamp) AS created_at +FROM events.raw_events +WHERE event_name = 'profile view' +AND target_id IS NOT NULL +GROUP BY date, target_id; diff --git a/src/common/schema/userProfileAnalytics.ts b/src/common/schema/userProfileAnalytics.ts new file mode 100644 index 0000000000..1325996d8a --- /dev/null +++ b/src/common/schema/userProfileAnalytics.ts @@ -0,0 +1,13 @@ +import { format } from 'date-fns'; +import { z } from 'zod'; + +export const userProfileAnalyticsClickhouseSchema = z.strictObject({ + id: z.string(), + updatedAt: z.coerce.date(), + uniqueVisitors: z.coerce.number().nonnegative(), +}); + +export const userProfileAnalyticsHistoryClickhouseSchema = + userProfileAnalyticsClickhouseSchema.extend({ + date: z.coerce.date().transform((date) => format(date, 'yyyy-MM-dd')), + }); diff --git a/src/common/user.ts b/src/common/user.ts index a73354d8ee..57baacfc18 100644 --- a/src/common/user.ts +++ b/src/common/user.ts @@ -233,3 +233,27 @@ export const checkCoresAccess = async ({ return checkUserCoresAccess({ user, requiredRole }); }; + +export const ensureUserProfileAnalyticsPermissions = async ({ + ctx, + userId, +}: { + ctx: AuthContext; + userId: string; +}): Promise => { + const { userId: requesterId, isTeamMember } = ctx; + + if (isTeamMember) { + return; + } + + if (!requesterId) { + throw new ForbiddenError('Auth is required'); + } + + if (requesterId !== userId) { + throw new ForbiddenError( + 'You do not have permission to view this profile analytics', + ); + } +}; diff --git a/src/cron/index.ts b/src/cron/index.ts index cd2c735e39..c17939ca82 100644 --- a/src/cron/index.ts +++ b/src/cron/index.ts @@ -21,6 +21,8 @@ import cleanGiftedPlus from './cleanGiftedPlus'; import { cleanStaleUserTransactions } from './cleanStaleUserTransactions'; import { postAnalyticsClickhouseCron } from './postAnalyticsClickhouse'; import { postAnalyticsHistoryDayClickhouseCron } from './postAnalyticsHistoryDayClickhouse'; +import { userProfileAnalyticsClickhouseCron } from './userProfileAnalyticsClickhouse'; +import { userProfileAnalyticsHistoryClickhouseCron } from './userProfileAnalyticsHistoryClickhouse'; import { cleanZombieOpportunities } from './cleanZombieOpportunities'; import { userProfileUpdatedSync } from './userProfileUpdatedSync'; @@ -47,6 +49,8 @@ export const crons: Cron[] = [ cleanStaleUserTransactions, postAnalyticsClickhouseCron, postAnalyticsHistoryDayClickhouseCron, + userProfileAnalyticsClickhouseCron, + userProfileAnalyticsHistoryClickhouseCron, cleanZombieOpportunities, userProfileUpdatedSync, ]; diff --git a/src/cron/userProfileAnalyticsClickhouse.ts b/src/cron/userProfileAnalyticsClickhouse.ts new file mode 100644 index 0000000000..9075d674b2 --- /dev/null +++ b/src/cron/userProfileAnalyticsClickhouse.ts @@ -0,0 +1,118 @@ +import { format, startOfToday } from 'date-fns'; +import { z } from 'zod'; +import { Cron } from './cron'; +import { getClickHouseClient } from '../common/clickhouse'; +import { userProfileAnalyticsClickhouseSchema } from '../common/schema/userProfileAnalytics'; +import { UserProfileAnalytics } from '../entity/user/UserProfileAnalytics'; +import { getRedisHash, setRedisHash } from '../redis'; +import { generateStorageKey, StorageTopic } from '../config'; + +type UserProfileAnalyticsClickhouseCronConfig = Partial<{ + lastRunAt: string; +}>; + +export const userProfileAnalyticsClickhouseCron: Cron = { + name: 'user-profile-analytics-clickhouse', + handler: async (con, logger) => { + const redisStorageKey = generateStorageKey( + StorageTopic.Cron, + userProfileAnalyticsClickhouseCron.name, + 'config', + ); + + const cronConfig: Partial = + await getRedisHash(redisStorageKey); + + const lastRunAt = cronConfig.lastRunAt + ? new Date(cronConfig.lastRunAt) + : startOfToday(); + + if (Number.isNaN(lastRunAt.getTime())) { + throw new Error('Invalid last run time'); + } + + const clickhouseClient = getClickHouseClient(); + + const queryParams = { + lastRunAt: format(lastRunAt, 'yyyy-MM-dd HH:mm:ss'), + }; + + const response = await clickhouseClient.query({ + query: /* sql */ ` + SELECT + user_id AS id, + max(created_at) AS "updatedAt", + uniqMerge(unique_visitors) AS "uniqueVisitors" + FROM api.user_profile_analytics + FINAL + WHERE user_id IN ( + SELECT DISTINCT user_id + FROM api.user_profile_analytics + WHERE created_at > {lastRunAt: DateTime} + ) + GROUP BY id + HAVING "updatedAt" > {lastRunAt: DateTime} + ORDER BY "updatedAt" DESC; + `, + format: 'JSONEachRow', + query_params: queryParams, + }); + + const result = z + .array(userProfileAnalyticsClickhouseSchema) + .safeParse(await response.json()); + + if (!result.success) { + logger.error( + { schemaError: result.error.issues[0] }, + 'Invalid user profile analytics data', + ); + throw new Error('Invalid user profile analytics data'); + } + + const { data } = result; + + const chunks: UserProfileAnalytics[][] = []; + const chunkSize = 500; + + data.forEach((item) => { + if ( + chunks.length === 0 || + chunks[chunks.length - 1].length === chunkSize + ) { + chunks.push([]); + } + chunks[chunks.length - 1].push(item as UserProfileAnalytics); + }); + + const currentRunAt = new Date(); + + await con.transaction(async (entityManager) => { + for (const chunk of chunks) { + if (chunk.length === 0) { + continue; + } + + await entityManager + .createQueryBuilder() + .insert() + .into(UserProfileAnalytics) + .values(chunk) + .orUpdate(Object.keys(chunk[0]), ['id']) + .execute(); + } + }); + + await setRedisHash( + redisStorageKey, + { + lastRunAt: currentRunAt.toISOString(), + }, + ); + + logger.info( + { rows: data.length, queryParams }, + 'synced user profile analytics data', + ); + }, +}; diff --git a/src/cron/userProfileAnalyticsHistoryClickhouse.ts b/src/cron/userProfileAnalyticsHistoryClickhouse.ts new file mode 100644 index 0000000000..2a42c513fd --- /dev/null +++ b/src/cron/userProfileAnalyticsHistoryClickhouse.ts @@ -0,0 +1,115 @@ +import { format, startOfToday } from 'date-fns'; +import { z } from 'zod'; +import { Cron } from './cron'; +import { getClickHouseClient } from '../common/clickhouse'; +import { userProfileAnalyticsHistoryClickhouseSchema } from '../common/schema/userProfileAnalytics'; +import { UserProfileAnalyticsHistory } from '../entity/user/UserProfileAnalyticsHistory'; +import { getRedisHash, setRedisHash } from '../redis'; +import { generateStorageKey, StorageTopic } from '../config'; + +type UserProfileAnalyticsHistoryClickhouseCronConfig = Partial<{ + lastRunAt: string; +}>; + +export const userProfileAnalyticsHistoryClickhouseCron: Cron = { + name: 'user-profile-analytics-history-clickhouse', + handler: async (con, logger) => { + const redisStorageKey = generateStorageKey( + StorageTopic.Cron, + userProfileAnalyticsHistoryClickhouseCron.name, + 'config', + ); + + const cronConfig: Partial = + await getRedisHash(redisStorageKey); + + const lastRunAt = cronConfig.lastRunAt + ? new Date(cronConfig.lastRunAt) + : startOfToday(); + + if (Number.isNaN(lastRunAt.getTime())) { + throw new Error('Invalid last run time'); + } + + const clickhouseClient = getClickHouseClient(); + + const queryParams = { + lastRunAt: format(lastRunAt, 'yyyy-MM-dd HH:mm:ss'), + date: format(new Date(), 'yyyy-MM-dd'), + }; + + const response = await clickhouseClient.query({ + query: /* sql */ ` + SELECT + user_id AS id, + date, + max(created_at) AS "updatedAt", + uniqMerge(unique_visitors) AS "uniqueVisitors" + FROM api.user_profile_analytics_history + FINAL + WHERE date = {date: Date} + GROUP BY date, id + HAVING "updatedAt" > {lastRunAt: DateTime} + `, + format: 'JSONEachRow', + query_params: queryParams, + }); + + const result = z + .array(userProfileAnalyticsHistoryClickhouseSchema) + .safeParse(await response.json()); + + if (!result.success) { + logger.error( + { schemaError: result.error.issues[0] }, + 'Invalid user profile analytics history data', + ); + throw new Error('Invalid user profile analytics history data'); + } + + const { data } = result; + + const chunks: UserProfileAnalyticsHistory[][] = []; + const chunkSize = 500; + + data.forEach((item) => { + if ( + chunks.length === 0 || + chunks[chunks.length - 1].length === chunkSize + ) { + chunks.push([]); + } + chunks[chunks.length - 1].push(item as UserProfileAnalyticsHistory); + }); + + const currentRunAt = new Date(); + + await con.transaction(async (entityManager) => { + for (const chunk of chunks) { + if (chunk.length === 0) { + continue; + } + + await entityManager + .createQueryBuilder() + .insert() + .into(UserProfileAnalyticsHistory) + .values(chunk) + .orUpdate(Object.keys(chunk[0]), ['id', 'date']) + .execute(); + } + }); + + await setRedisHash( + redisStorageKey, + { + lastRunAt: currentRunAt.toISOString(), + }, + ); + + logger.info( + { rows: data.length, queryParams }, + 'synced user profile analytics history data', + ); + }, +}; diff --git a/src/entity/user/UserProfileAnalytics.ts b/src/entity/user/UserProfileAnalytics.ts new file mode 100644 index 0000000000..02cbe6da12 --- /dev/null +++ b/src/entity/user/UserProfileAnalytics.ts @@ -0,0 +1,32 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import type { User } from './User'; + +@Entity() +export class UserProfileAnalytics { + @PrimaryColumn({ type: 'text' }) + id: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ default: 0 }) + uniqueVisitors: number; + + @OneToOne('User', { + lazy: true, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'id' }) + user: Promise; +} diff --git a/src/entity/user/UserProfileAnalyticsHistory.ts b/src/entity/user/UserProfileAnalyticsHistory.ts new file mode 100644 index 0000000000..ab25d0194e --- /dev/null +++ b/src/entity/user/UserProfileAnalyticsHistory.ts @@ -0,0 +1,35 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import type { User } from './User'; + +@Entity() +export class UserProfileAnalyticsHistory { + @PrimaryColumn({ type: 'text' }) + id: string; + + @PrimaryColumn({ type: 'text' }) + date: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ default: 0 }) + uniqueVisitors: number; + + @ManyToOne('User', { + lazy: true, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'id' }) + user: Promise; +} diff --git a/src/entity/user/index.ts b/src/entity/user/index.ts index 5112aa7439..f74af75c8d 100644 --- a/src/entity/user/index.ts +++ b/src/entity/user/index.ts @@ -7,3 +7,5 @@ export * from './UserPersonalizedDigest'; export * from './UserMarketingCta'; export * from './UserStats'; export * from './UserTopReader'; +export * from './UserProfileAnalytics'; +export * from './UserProfileAnalyticsHistory'; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index f918fa6a5b..bb2fcd39bd 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1634,6 +1634,25 @@ const obj = new GraphORM({ }, }, }, + UserProfileAnalytics: { + requiredColumns: ['id', 'updatedAt'], + fields: { + updatedAt: { + transform: transformDate, + }, + }, + }, + UserProfileAnalyticsHistory: { + requiredColumns: ['id', 'date', 'updatedAt'], + fields: { + date: { + transform: transformDate, + }, + updatedAt: { + transform: transformDate, + }, + }, + }, Opportunity: { requiredColumns: ['id', 'createdAt'], fields: { diff --git a/src/migration/1768505964487-UserProfileAnalytics.ts b/src/migration/1768505964487-UserProfileAnalytics.ts new file mode 100644 index 0000000000..a2b1df86dc --- /dev/null +++ b/src/migration/1768505964487-UserProfileAnalytics.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UserProfileAnalytics1768505964487 implements MigrationInterface { + name = 'UserProfileAnalytics1768506000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_profile_analytics" ( + "id" text NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + "uniqueVisitors" integer NOT NULL DEFAULT '0', + CONSTRAINT "PK_user_profile_analytics" PRIMARY KEY ("id") + )`, + ); + + await queryRunner.query( + `CREATE TABLE "user_profile_analytics_history" ( + "id" text NOT NULL, + "date" text NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + "uniqueVisitors" integer NOT NULL DEFAULT '0', + CONSTRAINT "PK_user_profile_analytics_history" PRIMARY KEY ("id", "date") + )`, + ); + + await queryRunner.query( + `ALTER TABLE "user_profile_analytics" + ADD CONSTRAINT "FK_user_profile_analytics_user" + FOREIGN KEY ("id") REFERENCES "user"("id") + ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "user_profile_analytics_history" + ADD CONSTRAINT "FK_user_profile_analytics_history_user" + FOREIGN KEY ("id") REFERENCES "user"("id") + ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile_analytics_history" DROP CONSTRAINT "FK_user_profile_analytics_history_user"`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile_analytics" DROP CONSTRAINT "FK_user_profile_analytics_user"`, + ); + await queryRunner.query(`DROP TABLE "user_profile_analytics_history"`); + await queryRunner.query(`DROP TABLE "user_profile_analytics"`); + } +} diff --git a/src/schema/users.ts b/src/schema/users.ts index 54f558f516..fd66dd5f87 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -103,6 +103,7 @@ import { import { checkUserCoresAccess, deleteUser, + ensureUserProfileAnalyticsPermissions, getUserCoresRole, } from '../common/user'; import { randomInt, randomUUID } from 'crypto'; @@ -314,6 +315,19 @@ export interface GQLUserPersonalizedDigest { flags: UserPersonalizedDigestFlagsPublic; } +export interface GQLUserProfileAnalytics { + id: string; + uniqueVisitors: number; + updatedAt: Date; +} + +export interface GQLUserProfileAnalyticsHistory { + id: string; + date: string; + uniqueVisitors: number; + updatedAt: Date; +} + export interface SendReportArgs { type: ReportEntity; id: string; @@ -1079,6 +1093,56 @@ export const typeDefs = /* GraphQL */ ` coresRole: Int! } + """ + User profile analytics with visitor data + """ + type UserProfileAnalytics { + """ + User ID + """ + id: ID! + """ + Total unique visitors to the profile + """ + uniqueVisitors: Int! + """ + Last update timestamp + """ + updatedAt: DateTime! + } + + """ + Daily user profile analytics history entry + """ + type UserProfileAnalyticsHistory { + """ + User ID + """ + id: ID! + """ + Date of the analytics (YYYY-MM-DD) + """ + date: DateTime! + """ + Unique visitors on this day + """ + uniqueVisitors: Int! + """ + Last update timestamp + """ + updatedAt: DateTime! + } + + type UserProfileAnalyticsHistoryEdge { + node: UserProfileAnalyticsHistory! + cursor: String! + } + + type UserProfileAnalyticsHistoryConnection { + pageInfo: PageInfo! + edges: [UserProfileAnalyticsHistoryEdge!]! + } + extend type Query { """ Get user based on logged in session @@ -1270,6 +1334,21 @@ export const typeDefs = /* GraphQL */ ` Get current user's notification preferences """ notificationSettings: JSON @auth + + """ + Get user profile analytics for the specified user + """ + userProfileAnalytics(userId: ID!): UserProfileAnalytics @auth + + """ + Get user profile analytics history (daily breakdown) + """ + userProfileAnalyticsHistory( + userId: ID! + after: String + before: String + first: Int + ): UserProfileAnalyticsHistoryConnection @auth } ${toGQLEnum(UploadPreset, 'UploadPreset')} @@ -2583,6 +2662,54 @@ export const resolvers: IResolvers = traceResolvers< ...user?.notificationFlags, }; }, + userProfileAnalytics: async ( + _, + args: { userId: string }, + ctx: AuthContext, + info: GraphQLResolveInfo, + ): Promise => { + await ensureUserProfileAnalyticsPermissions({ ctx, userId: args.userId }); + + return graphorm.queryOneOrFail( + ctx, + info, + (builder) => ({ + ...builder, + queryBuilder: builder.queryBuilder.andWhere( + `"${builder.alias}".id = :userId`, + { userId: args.userId }, + ), + }), + undefined, + true, + ); + }, + userProfileAnalyticsHistory: async ( + _, + args: ConnectionArguments & { userId: string }, + ctx: AuthContext, + info: GraphQLResolveInfo, + ): Promise> => { + await ensureUserProfileAnalyticsPermissions({ ctx, userId: args.userId }); + + return queryPaginatedByDate( + ctx, + info, + args, + { key: 'updatedAt' }, + { + queryBuilder: (builder) => { + builder.queryBuilder = builder.queryBuilder.andWhere( + `${builder.alias}.id = :userId`, + { userId: args.userId }, + ); + return builder; + }, + orderByKey: 'DESC', + readReplica: true, + }, + ); + }, }, Mutation: { clearImage: async ( From c92feba42064c55d3b949ed9484c2c146ff84ca0 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 19 Jan 2026 09:41:13 +0100 Subject: [PATCH 2/9] dont throw on no permission --- src/common/user.ts | 14 +++++--------- src/schema/users.ts | 14 +++++++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/common/user.ts b/src/common/user.ts index 57baacfc18..5e14ea2eda 100644 --- a/src/common/user.ts +++ b/src/common/user.ts @@ -234,26 +234,22 @@ export const checkCoresAccess = async ({ return checkUserCoresAccess({ user, requiredRole }); }; -export const ensureUserProfileAnalyticsPermissions = async ({ +export const hasUserProfileAnalyticsPermissions = ({ ctx, userId, }: { ctx: AuthContext; userId: string; -}): Promise => { +}): boolean => { const { userId: requesterId, isTeamMember } = ctx; if (isTeamMember) { - return; + return true; } if (!requesterId) { - throw new ForbiddenError('Auth is required'); + return false; } - if (requesterId !== userId) { - throw new ForbiddenError( - 'You do not have permission to view this profile analytics', - ); - } + return requesterId === userId; }; diff --git a/src/schema/users.ts b/src/schema/users.ts index fd66dd5f87..3125161726 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -103,8 +103,8 @@ import { import { checkUserCoresAccess, deleteUser, - ensureUserProfileAnalyticsPermissions, getUserCoresRole, + hasUserProfileAnalyticsPermissions, } from '../common/user'; import { randomInt, randomUUID } from 'crypto'; import { ArrayContains, DataSource, In, IsNull, QueryRunner } from 'typeorm'; @@ -2667,8 +2667,10 @@ export const resolvers: IResolvers = traceResolvers< args: { userId: string }, ctx: AuthContext, info: GraphQLResolveInfo, - ): Promise => { - await ensureUserProfileAnalyticsPermissions({ ctx, userId: args.userId }); + ): Promise => { + if (!hasUserProfileAnalyticsPermissions({ ctx, userId: args.userId })) { + return null; + } return graphorm.queryOneOrFail( ctx, @@ -2689,8 +2691,10 @@ export const resolvers: IResolvers = traceResolvers< args: ConnectionArguments & { userId: string }, ctx: AuthContext, info: GraphQLResolveInfo, - ): Promise> => { - await ensureUserProfileAnalyticsPermissions({ ctx, userId: args.userId }); + ): Promise | null> => { + if (!hasUserProfileAnalyticsPermissions({ ctx, userId: args.userId })) { + return null; + } return queryPaginatedByDate( ctx, From 91cade6c121a08b510451e501c5ff52adf10a874 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 19 Jan 2026 11:06:23 +0100 Subject: [PATCH 3/9] tests --- __tests__/users.ts | 189 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/__tests__/users.ts b/__tests__/users.ts index 1547be180f..906079bb0e 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -57,6 +57,8 @@ import { UserTopReader, View, } from '../src/entity'; +import { UserProfileAnalytics } from '../src/entity/user/UserProfileAnalytics'; +import { UserProfileAnalyticsHistory } from '../src/entity/user/UserProfileAnalyticsHistory'; import { sourcesFixture } from './fixture/source'; import { CioTransactionalMessageTemplateId, @@ -168,6 +170,7 @@ let state: GraphQLTestingState; let client: GraphQLTestClient; let loggedUser: string = null; let isPlus: boolean; +let isTeamMember = false; const userTimezone = 'Pacific/Midway'; jest.mock('../src/common/paddle/index.ts', () => ({ @@ -206,7 +209,7 @@ beforeAll(async () => { loggedUser, undefined, undefined, - undefined, + isTeamMember, isPlus, 'US', ), @@ -220,6 +223,7 @@ const now = new Date(); beforeEach(async () => { loggedUser = null; isPlus = false; + isTeamMember = false; nock.cleanAll(); jest.clearAllMocks(); @@ -7589,3 +7593,186 @@ describe('mutation clearResume', () => { ).toEqual(0); }); }); + +describe('query userProfileAnalytics', () => { + const QUERY = ` + query UserProfileAnalytics($userId: ID!) { + userProfileAnalytics(userId: $userId) { + id + uniqueVisitors + updatedAt + } + } + `; + + it('should return null for unauthenticated user', async () => { + loggedUser = null; + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalytics).toBeNull(); + }); + + it('should return null when viewing another user analytics', async () => { + loggedUser = '2'; + + await con.getRepository(UserProfileAnalytics).save({ + id: '1', + uniqueVisitors: 150, + }); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalytics).toBeNull(); + }); + + it('should return analytics for own profile', async () => { + loggedUser = '1'; + + const analytics = await con.getRepository(UserProfileAnalytics).save({ + id: '1', + uniqueVisitors: 150, + }); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalytics).toMatchObject({ + id: '1', + uniqueVisitors: 150, + updatedAt: analytics.updatedAt.toISOString(), + }); + }); + + it('should allow team member to view any user analytics', async () => { + loggedUser = '2'; + isTeamMember = true; + + const analytics = await con.getRepository(UserProfileAnalytics).save({ + id: '1', + uniqueVisitors: 150, + }); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalytics).toMatchObject({ + id: '1', + uniqueVisitors: 150, + updatedAt: analytics.updatedAt.toISOString(), + }); + }); + + it('should return null when no analytics record exists', async () => { + loggedUser = '1'; + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalytics).toBeNull(); + }); +}); + +describe('query userProfileAnalyticsHistory', () => { + const QUERY = ` + query UserProfileAnalyticsHistory($userId: ID!, $first: Int, $after: String) { + userProfileAnalyticsHistory(userId: $userId, first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + date + uniqueVisitors + updatedAt + } + } + } + } + `; + + it('should return null for unauthenticated user', async () => { + loggedUser = null; + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalyticsHistory).toBeNull(); + }); + + it('should return null when viewing another user history', async () => { + loggedUser = '2'; + + await con + .getRepository(UserProfileAnalyticsHistory) + .save([{ id: '1', date: '2026-01-15', uniqueVisitors: 10 }]); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalyticsHistory).toBeNull(); + }); + + it('should return history for own profile', async () => { + loggedUser = '1'; + + await con.getRepository(UserProfileAnalyticsHistory).save([ + { id: '1', date: '2026-01-15', uniqueVisitors: 10 }, + { id: '1', date: '2026-01-14', uniqueVisitors: 25 }, + { id: '1', date: '2026-01-13', uniqueVisitors: 15 }, + ]); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalyticsHistory.edges).toHaveLength(3); + expect(res.data.userProfileAnalyticsHistory.edges[0].node).toMatchObject({ + id: '1', + date: '2026-01-15', + uniqueVisitors: 10, + }); + }); + + it('should allow team member to view any user history', async () => { + loggedUser = '2'; + isTeamMember = true; + + await con + .getRepository(UserProfileAnalyticsHistory) + .save([{ id: '1', date: '2026-01-15', uniqueVisitors: 10 }]); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalyticsHistory.edges).toHaveLength(1); + expect(res.data.userProfileAnalyticsHistory.edges[0].node).toMatchObject({ + id: '1', + date: '2026-01-15', + uniqueVisitors: 10, + }); + }); + + it('should paginate with first parameter', async () => { + loggedUser = '1'; + + await con.getRepository(UserProfileAnalyticsHistory).save([ + { id: '1', date: '2026-01-15', uniqueVisitors: 10 }, + { id: '1', date: '2026-01-14', uniqueVisitors: 25 }, + { id: '1', date: '2026-01-13', uniqueVisitors: 15 }, + { id: '1', date: '2026-01-12', uniqueVisitors: 20 }, + { id: '1', date: '2026-01-11', uniqueVisitors: 30 }, + ]); + + const res = await client.query(QUERY, { + variables: { userId: '1', first: 2 }, + }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalyticsHistory.edges).toHaveLength(2); + expect(res.data.userProfileAnalyticsHistory.pageInfo.hasNextPage).toBe( + true, + ); + }); + + it('should return empty edges when no history exists', async () => { + loggedUser = '1'; + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.userProfileAnalyticsHistory.edges).toHaveLength(0); + }); +}); From e051e8cb942a4ab9de2ffdf48810d851f8b25994 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 19 Jan 2026 11:20:44 +0100 Subject: [PATCH 4/9] remove unnecessary comment --- .../20260115100000_user_profile_analytics.down.sql | 2 -- .../migrations/20260115100000_user_profile_analytics.up.sql | 2 -- src/schema/users.ts | 5 +---- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql b/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql index 02122a5abd..8a902ee04d 100644 --- a/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql +++ b/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql @@ -1,7 +1,5 @@ --- Drop MVs first (they depend on tables) DROP VIEW IF EXISTS api.user_profile_analytics_history_mv; DROP VIEW IF EXISTS api.user_profile_analytics_mv; --- Drop tables DROP TABLE IF EXISTS api.user_profile_analytics_history; DROP TABLE IF EXISTS api.user_profile_analytics; diff --git a/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql b/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql index 48160f6bbb..48bf248f09 100644 --- a/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql +++ b/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql @@ -1,4 +1,3 @@ --- Main aggregation table for user profile analytics CREATE TABLE api.user_profile_analytics ( user_id String, @@ -9,7 +8,6 @@ ENGINE = AggregatingMergeTree PARTITION BY toYYYYMM(created_at) ORDER BY user_id; --- History table for daily aggregates CREATE TABLE api.user_profile_analytics_history ( user_id String, diff --git a/src/schema/users.ts b/src/schema/users.ts index 3125161726..0ef1560e1e 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -321,11 +321,8 @@ export interface GQLUserProfileAnalytics { updatedAt: Date; } -export interface GQLUserProfileAnalyticsHistory { - id: string; +export interface GQLUserProfileAnalyticsHistory extends GQLUserProfileAnalytics { date: string; - uniqueVisitors: number; - updatedAt: Date; } export interface SendReportArgs { From 89e88242f3d61bc9f138284e82344219de43cad0 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Mon, 19 Jan 2026 11:40:15 +0100 Subject: [PATCH 5/9] fix test --- __tests__/users.ts | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/__tests__/users.ts b/__tests__/users.ts index 906079bb0e..1862d77c28 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -7605,13 +7605,12 @@ describe('query userProfileAnalytics', () => { } `; - it('should return null for unauthenticated user', async () => { - loggedUser = null; - - const res = await client.query(QUERY, { variables: { userId: '1' } }); - expect(res.errors).toBeFalsy(); - expect(res.data.userProfileAnalytics).toBeNull(); - }); + it('should not allow unauthenticated users', () => + testQueryErrorCode( + client, + { query: QUERY, variables: { userId: '1' } }, + 'UNAUTHENTICATED', + )); it('should return null when viewing another user analytics', async () => { loggedUser = '2'; @@ -7661,12 +7660,14 @@ describe('query userProfileAnalytics', () => { }); }); - it('should return null when no analytics record exists', async () => { + it('should return not found error when no analytics record exists', () => { loggedUser = '1'; - const res = await client.query(QUERY, { variables: { userId: '1' } }); - expect(res.errors).toBeFalsy(); - expect(res.data.userProfileAnalytics).toBeNull(); + return testQueryErrorCode( + client, + { query: QUERY, variables: { userId: '1' } }, + 'NOT_FOUND', + ); }); }); @@ -7690,13 +7691,12 @@ describe('query userProfileAnalyticsHistory', () => { } `; - it('should return null for unauthenticated user', async () => { - loggedUser = null; - - const res = await client.query(QUERY, { variables: { userId: '1' } }); - expect(res.errors).toBeFalsy(); - expect(res.data.userProfileAnalyticsHistory).toBeNull(); - }); + it('should not allow unauthenticated users', () => + testQueryErrorCode( + client, + { query: QUERY, variables: { userId: '1' } }, + 'UNAUTHENTICATED', + )); it('should return null when viewing another user history', async () => { loggedUser = '2'; @@ -7724,7 +7724,7 @@ describe('query userProfileAnalyticsHistory', () => { expect(res.data.userProfileAnalyticsHistory.edges).toHaveLength(3); expect(res.data.userProfileAnalyticsHistory.edges[0].node).toMatchObject({ id: '1', - date: '2026-01-15', + date: '2026-01-15T00:00:00.000Z', uniqueVisitors: 10, }); }); @@ -7742,7 +7742,7 @@ describe('query userProfileAnalyticsHistory', () => { expect(res.data.userProfileAnalyticsHistory.edges).toHaveLength(1); expect(res.data.userProfileAnalyticsHistory.edges[0].node).toMatchObject({ id: '1', - date: '2026-01-15', + date: '2026-01-15T00:00:00.000Z', uniqueVisitors: 10, }); }); From 7f64dc169d534cd0484fe792160126b57a93c114 Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 23 Jan 2026 11:25:42 +0100 Subject: [PATCH 6/9] feat: add mv settings and date filter --- .../20260115100000_user_profile_analytics.up.sql | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql b/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql index 48bf248f09..06960f5be0 100644 --- a/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql +++ b/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql @@ -30,7 +30,9 @@ SELECT FROM events.raw_events WHERE event_name = 'profile view' AND target_id IS NOT NULL -GROUP BY target_id; +AND server_timestamp > '2026-01-23 10:25:00' +GROUP BY target_id +SETTINGS materialized_views_ignore_errors = 1; -- MV for daily history CREATE MATERIALIZED VIEW api.user_profile_analytics_history_mv @@ -44,4 +46,6 @@ SELECT FROM events.raw_events WHERE event_name = 'profile view' AND target_id IS NOT NULL -GROUP BY date, target_id; +AND server_timestamp > '2026-01-23 10:25:00' +GROUP BY date, target_id +SETTINGS materialized_views_ignore_errors = 1; From d46e79808f0e77b9ffa0989e21fceb32e6cc7886 Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 23 Jan 2026 12:05:08 +0100 Subject: [PATCH 7/9] feat: query adjustments --- ...0115100000_user_profile_analytics.down.sql | 4 +-- ...260115100000_user_profile_analytics.up.sql | 32 +++++++++---------- src/cron/userProfileAnalyticsClickhouse.ts | 2 +- .../userProfileAnalyticsHistoryClickhouse.ts | 2 +- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql b/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql index 8a902ee04d..d37b038d63 100644 --- a/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql +++ b/clickhouse/migrations/20260115100000_user_profile_analytics.down.sql @@ -1,5 +1,5 @@ -DROP VIEW IF EXISTS api.user_profile_analytics_history_mv; -DROP VIEW IF EXISTS api.user_profile_analytics_mv; +DROP TABLE IF EXISTS api.user_profile_analytics_history_mv; +DROP TABLE IF EXISTS api.user_profile_analytics_mv; DROP TABLE IF EXISTS api.user_profile_analytics_history; DROP TABLE IF EXISTS api.user_profile_analytics; diff --git a/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql b/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql index 06960f5be0..21a52b9cb7 100644 --- a/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql +++ b/clickhouse/migrations/20260115100000_user_profile_analytics.up.sql @@ -1,51 +1,51 @@ -CREATE TABLE api.user_profile_analytics +CREATE TABLE IF NOT EXISTS api.user_profile_analytics ( - user_id String, + profile_id String, created_at SimpleAggregateFunction(max, DateTime64(3)), unique_visitors AggregateFunction(uniq, String) ) ENGINE = AggregatingMergeTree PARTITION BY toYYYYMM(created_at) -ORDER BY user_id; +ORDER BY profile_id; -CREATE TABLE api.user_profile_analytics_history +CREATE TABLE IF NOT EXISTS api.user_profile_analytics_history ( - user_id String, + profile_id String, date Date, created_at SimpleAggregateFunction(max, DateTime64(3)), unique_visitors AggregateFunction(uniq, String) ) ENGINE = AggregatingMergeTree PARTITION BY toYYYYMM(created_at) -ORDER BY (date, user_id); +ORDER BY (date, profile_id); -- MV for main aggregation (all-time totals) -CREATE MATERIALIZED VIEW api.user_profile_analytics_mv +CREATE MATERIALIZED VIEW IF NOT EXISTS api.user_profile_analytics_mv TO api.user_profile_analytics AS SELECT - target_id AS user_id, - uniqState(user_id) AS unique_visitors, + target_id AS profile_id, + uniqStateIf(user_id, event_name = 'profile view') AS unique_visitors, max(server_timestamp) AS created_at FROM events.raw_events -WHERE event_name = 'profile view' +WHERE event_name IN ('profile view') AND target_id IS NOT NULL -AND server_timestamp > '2026-01-23 10:25:00' +AND server_timestamp > '2026-01-23 11:04:00' GROUP BY target_id SETTINGS materialized_views_ignore_errors = 1; -- MV for daily history -CREATE MATERIALIZED VIEW api.user_profile_analytics_history_mv +CREATE MATERIALIZED VIEW IF NOT EXISTS api.user_profile_analytics_history_mv TO api.user_profile_analytics_history AS SELECT - target_id AS user_id, + target_id AS profile_id, toDate(server_timestamp) AS date, - uniqState(user_id) AS unique_visitors, + uniqStateIf(user_id, event_name = 'profile view') AS unique_visitors, max(server_timestamp) AS created_at FROM events.raw_events -WHERE event_name = 'profile view' +WHERE event_name IN ('profile view') AND target_id IS NOT NULL -AND server_timestamp > '2026-01-23 10:25:00' +AND server_timestamp > '2026-01-23 11:04:00' GROUP BY date, target_id SETTINGS materialized_views_ignore_errors = 1; diff --git a/src/cron/userProfileAnalyticsClickhouse.ts b/src/cron/userProfileAnalyticsClickhouse.ts index 9075d674b2..795c0b4d63 100644 --- a/src/cron/userProfileAnalyticsClickhouse.ts +++ b/src/cron/userProfileAnalyticsClickhouse.ts @@ -40,7 +40,7 @@ export const userProfileAnalyticsClickhouseCron: Cron = { const response = await clickhouseClient.query({ query: /* sql */ ` SELECT - user_id AS id, + profile_id AS id, max(created_at) AS "updatedAt", uniqMerge(unique_visitors) AS "uniqueVisitors" FROM api.user_profile_analytics diff --git a/src/cron/userProfileAnalyticsHistoryClickhouse.ts b/src/cron/userProfileAnalyticsHistoryClickhouse.ts index 2a42c513fd..9e2971847e 100644 --- a/src/cron/userProfileAnalyticsHistoryClickhouse.ts +++ b/src/cron/userProfileAnalyticsHistoryClickhouse.ts @@ -41,7 +41,7 @@ export const userProfileAnalyticsHistoryClickhouseCron: Cron = { const response = await clickhouseClient.query({ query: /* sql */ ` SELECT - user_id AS id, + profile_id AS id, date, max(created_at) AS "updatedAt", uniqMerge(unique_visitors) AS "uniqueVisitors" From 2fd5f6bf793409181a66ca9ed77bcc16f03e8950 Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 23 Jan 2026 12:11:25 +0100 Subject: [PATCH 8/9] feat: adjust cron timings --- .infra/crons.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.infra/crons.ts b/.infra/crons.ts index 393994cf7b..ad7ee9f823 100644 --- a/.infra/crons.ts +++ b/.infra/crons.ts @@ -132,11 +132,11 @@ export const crons: Cron[] = [ }, { name: 'user-profile-analytics-clickhouse', - schedule: '*/5 * * * *', + schedule: '7 */1 * * *', }, { name: 'user-profile-analytics-history-clickhouse', - schedule: '3-59/5 * * * *', + schedule: '15 */1 * * *', }, { name: 'clean-zombie-opportunities', From 1c2c9c12ab67fdd8b9b16a6b0aa261e7e05319ae Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Fri, 23 Jan 2026 15:31:57 +0100 Subject: [PATCH 9/9] tests --- .../cron/userProfileAnalyticsClickhouse.ts | 220 +++++++++++++++++ .../userProfileAnalyticsHistoryClickhouse.ts | 232 ++++++++++++++++++ __tests__/fixture/userProfileAnalytics.ts | 9 + 3 files changed, 461 insertions(+) create mode 100644 __tests__/cron/userProfileAnalyticsClickhouse.ts create mode 100644 __tests__/cron/userProfileAnalyticsHistoryClickhouse.ts create mode 100644 __tests__/fixture/userProfileAnalytics.ts diff --git a/__tests__/cron/userProfileAnalyticsClickhouse.ts b/__tests__/cron/userProfileAnalyticsClickhouse.ts new file mode 100644 index 0000000000..20ee488820 --- /dev/null +++ b/__tests__/cron/userProfileAnalyticsClickhouse.ts @@ -0,0 +1,220 @@ +import { crons } from '../../src/cron/index'; +import { userProfileAnalyticsClickhouseCron as cron } from '../../src/cron/userProfileAnalyticsClickhouse'; +import { + expectSuccessfulCron, + mockClickhouseClientOnce, + mockClickhouseQueryJSONOnce, + saveFixtures, +} from '../helpers'; +import { userProfileAnalyticsFixture } from '../fixture/userProfileAnalytics'; +import { usersFixture, plusUsersFixture } from '../fixture/user'; +import createOrGetConnection from '../../src/db'; +import type { DataSource } from 'typeorm'; +import { UserProfileAnalytics } from '../../src/entity/user/UserProfileAnalytics'; +import { User } from '../../src/entity/user/User'; +import { deleteRedisKey, getRedisHash } from '../../src/redis'; +import { generateStorageKey, StorageTopic } from '../../src/config'; +import { format, startOfToday } from 'date-fns'; + +let con: DataSource; + +beforeAll(async () => { + con = await createOrGetConnection(); +}); + +const cronConfigRedisKey = generateStorageKey( + StorageTopic.Cron, + cron.name, + 'config', +); + +beforeEach(async () => { + jest.clearAllMocks(); + await deleteRedisKey(cronConfigRedisKey); + await saveFixtures(con, User, [...usersFixture, ...plusUsersFixture]); +}); + +const userIds = ['1', '2', '3', '4', '5']; + +describe('userProfileAnalyticsClickhouse cron', () => { + it('should be registered', () => { + const registeredWorker = crons.find((item) => item.name === cron.name); + + expect(registeredWorker).toBeDefined(); + }); + + it('should sync user profile analytics data', async () => { + const clickhouseClientMock = mockClickhouseClientOnce(); + + const queryJSONSpy = mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + ...item, + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + })), + ); + + const now = new Date(); + + await expectSuccessfulCron(cron); + + expect(queryJSONSpy).toHaveBeenCalledTimes(1); + expect(queryJSONSpy).toHaveBeenCalledWith({ + query: expect.stringContaining('SELECT'), + format: 'JSONEachRow', + query_params: { + lastRunAt: format(startOfToday(), 'yyyy-MM-dd HH:mm:ss'), + }, + }); + + const userProfileAnalytics = await con + .getRepository(UserProfileAnalytics) + .find({ + order: { + updatedAt: 'ASC', + }, + }); + + expect(userProfileAnalytics.length).toBe( + userProfileAnalyticsFixture.length, + ); + + userProfileAnalytics.forEach((item, index) => { + expect(item).toEqual({ + id: userIds[index], + updatedAt: expect.any(Date), + createdAt: expect.any(Date), + uniqueVisitors: userProfileAnalyticsFixture[index].uniqueVisitors, + } as UserProfileAnalytics); + }); + + const cronConfig = await getRedisHash(cronConfigRedisKey); + + expect(cronConfig).toBeDefined(); + expect(cronConfig.lastRunAt).toBeDefined(); + expect(new Date(cronConfig.lastRunAt).getTime()).toBeGreaterThan( + now.getTime(), + ); + }); + + it('should use lastRunAt from previous run', async () => { + let clickhouseClientMock = mockClickhouseClientOnce(); + + mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + ...item, + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + })), + ); + + await expectSuccessfulCron(cron); + + const userProfileAnalytics = await con + .getRepository(UserProfileAnalytics) + .find({ + order: { + updatedAt: 'ASC', + }, + }); + + expect(userProfileAnalytics.length).toBe( + userProfileAnalyticsFixture.length, + ); + + userProfileAnalytics.forEach((item, index) => { + expect(item).toEqual({ + id: userIds[index], + updatedAt: expect.any(Date), + createdAt: expect.any(Date), + uniqueVisitors: userProfileAnalyticsFixture[index].uniqueVisitors, + } as UserProfileAnalytics); + }); + + const lastCronConfig = await getRedisHash(cronConfigRedisKey); + + clickhouseClientMock = mockClickhouseClientOnce(); + + const queryJSONSpy = mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + ...item, + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + })), + ); + + await expectSuccessfulCron(cron); + + expect(queryJSONSpy).toHaveBeenCalledTimes(1); + expect(queryJSONSpy).toHaveBeenCalledWith({ + query: expect.stringContaining('SELECT'), + format: 'JSONEachRow', + query_params: { + lastRunAt: format( + new Date(lastCronConfig.lastRunAt), + 'yyyy-MM-dd HH:mm:ss', + ), + }, + }); + + const cronConfig = await getRedisHash(cronConfigRedisKey); + + expect(cronConfig).toBeDefined(); + expect(cronConfig.lastRunAt).toBeDefined(); + expect(new Date(cronConfig.lastRunAt).getTime()).toBeGreaterThan( + new Date(lastCronConfig.lastRunAt).getTime(), + ); + }); + + it('should upsert user profile analytics data on repeated runs', async () => { + let clickhouseClientMock = mockClickhouseClientOnce(); + + mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + ...item, + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + })), + ); + + await expectSuccessfulCron(cron); + + clickhouseClientMock = mockClickhouseClientOnce(); + + mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + ...item, + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + uniqueVisitors: item.uniqueVisitors + 100, + })), + ); + + await expectSuccessfulCron(cron); + + const userProfileAnalytics = await con + .getRepository(UserProfileAnalytics) + .find({ + select: ['id', 'uniqueVisitors'], + order: { + updatedAt: 'ASC', + }, + }); + + expect(userProfileAnalytics.length).toBe( + userProfileAnalyticsFixture.length, + ); + + userProfileAnalytics.forEach((item, index) => { + expect(item).toEqual({ + id: userIds[index], + uniqueVisitors: userProfileAnalyticsFixture[index].uniqueVisitors + 100, + } as UserProfileAnalytics); + }); + }); +}); diff --git a/__tests__/cron/userProfileAnalyticsHistoryClickhouse.ts b/__tests__/cron/userProfileAnalyticsHistoryClickhouse.ts new file mode 100644 index 0000000000..f18f610120 --- /dev/null +++ b/__tests__/cron/userProfileAnalyticsHistoryClickhouse.ts @@ -0,0 +1,232 @@ +import { crons } from '../../src/cron/index'; +import { userProfileAnalyticsHistoryClickhouseCron as cron } from '../../src/cron/userProfileAnalyticsHistoryClickhouse'; +import { + expectSuccessfulCron, + mockClickhouseClientOnce, + mockClickhouseQueryJSONOnce, + saveFixtures, +} from '../helpers'; +import { userProfileAnalyticsFixture } from '../fixture/userProfileAnalytics'; +import { usersFixture, plusUsersFixture } from '../fixture/user'; +import createOrGetConnection from '../../src/db'; +import type { DataSource } from 'typeorm'; +import { UserProfileAnalyticsHistory } from '../../src/entity/user/UserProfileAnalyticsHistory'; +import { User } from '../../src/entity/user/User'; +import { deleteRedisKey, getRedisHash } from '../../src/redis'; +import { generateStorageKey, StorageTopic } from '../../src/config'; +import { format, startOfToday } from 'date-fns'; + +let con: DataSource; + +beforeAll(async () => { + con = await createOrGetConnection(); +}); + +const cronConfigRedisKey = generateStorageKey( + StorageTopic.Cron, + cron.name, + 'config', +); + +beforeEach(async () => { + jest.clearAllMocks(); + await deleteRedisKey(cronConfigRedisKey); + await saveFixtures(con, User, [...usersFixture, ...plusUsersFixture]); +}); + +const userIds = ['1', '2', '3', '4', '5']; + +describe('userProfileAnalyticsHistoryClickhouse cron', () => { + it('should be registered', () => { + const registeredWorker = crons.find((item) => item.name === cron.name); + + expect(registeredWorker).toBeDefined(); + }); + + it('should sync user profile analytics history data', async () => { + const clickhouseClientMock = mockClickhouseClientOnce(); + const date = format(new Date(), 'yyyy-MM-dd'); + + const queryJSONSpy = mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + date, + uniqueVisitors: item.uniqueVisitors, + })), + ); + + const now = new Date(); + + await expectSuccessfulCron(cron); + + expect(queryJSONSpy).toHaveBeenCalledTimes(1); + expect(queryJSONSpy).toHaveBeenCalledWith({ + query: expect.stringContaining('SELECT'), + format: 'JSONEachRow', + query_params: { + date, + lastRunAt: format(startOfToday(), 'yyyy-MM-dd HH:mm:ss'), + }, + }); + + const userProfileAnalyticsHistory = await con + .getRepository(UserProfileAnalyticsHistory) + .find({ + order: { + updatedAt: 'ASC', + }, + }); + + expect(userProfileAnalyticsHistory.length).toBe( + userProfileAnalyticsFixture.length, + ); + + userProfileAnalyticsHistory.forEach((item, index) => { + expect(item).toEqual({ + id: userIds[index], + updatedAt: expect.any(Date), + createdAt: expect.any(Date), + uniqueVisitors: userProfileAnalyticsFixture[index].uniqueVisitors, + date, + } as UserProfileAnalyticsHistory); + }); + + const cronConfig = await getRedisHash(cronConfigRedisKey); + + expect(cronConfig).toBeDefined(); + expect(cronConfig.lastRunAt).toBeDefined(); + expect(new Date(cronConfig.lastRunAt).getTime()).toBeGreaterThan( + now.getTime(), + ); + }); + + it('should use lastRunAt from previous run', async () => { + let clickhouseClientMock = mockClickhouseClientOnce(); + const date = format(new Date(), 'yyyy-MM-dd'); + + mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + date, + uniqueVisitors: item.uniqueVisitors, + })), + ); + + await expectSuccessfulCron(cron); + + const userProfileAnalyticsHistory = await con + .getRepository(UserProfileAnalyticsHistory) + .find({ + order: { + updatedAt: 'ASC', + }, + }); + + expect(userProfileAnalyticsHistory.length).toBe( + userProfileAnalyticsFixture.length, + ); + + userProfileAnalyticsHistory.forEach((item, index) => { + expect(item).toEqual({ + id: userIds[index], + updatedAt: expect.any(Date), + createdAt: expect.any(Date), + uniqueVisitors: userProfileAnalyticsFixture[index].uniqueVisitors, + date, + } as UserProfileAnalyticsHistory); + }); + + const lastCronConfig = await getRedisHash(cronConfigRedisKey); + + clickhouseClientMock = mockClickhouseClientOnce(); + + const queryJSONSpy = mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + date, + uniqueVisitors: item.uniqueVisitors, + })), + ); + + await expectSuccessfulCron(cron); + + expect(queryJSONSpy).toHaveBeenCalledTimes(1); + expect(queryJSONSpy).toHaveBeenCalledWith({ + query: expect.stringContaining('SELECT'), + format: 'JSONEachRow', + query_params: { + date, + lastRunAt: format( + new Date(lastCronConfig.lastRunAt), + 'yyyy-MM-dd HH:mm:ss', + ), + }, + }); + + const cronConfig = await getRedisHash(cronConfigRedisKey); + + expect(cronConfig).toBeDefined(); + expect(cronConfig.lastRunAt).toBeDefined(); + expect(new Date(cronConfig.lastRunAt).getTime()).toBeGreaterThan( + new Date(lastCronConfig.lastRunAt).getTime(), + ); + }); + + it('should upsert user profile analytics history data on repeated runs', async () => { + let clickhouseClientMock = mockClickhouseClientOnce(); + const date = format(new Date(), 'yyyy-MM-dd'); + + mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + date, + uniqueVisitors: item.uniqueVisitors, + })), + ); + + await expectSuccessfulCron(cron); + + clickhouseClientMock = mockClickhouseClientOnce(); + + mockClickhouseQueryJSONOnce( + clickhouseClientMock, + userProfileAnalyticsFixture.map((item, index) => ({ + updatedAt: new Date(Date.now() + index * 1000).toISOString(), + id: userIds[index], + date, + uniqueVisitors: item.uniqueVisitors + 50, + })), + ); + + await expectSuccessfulCron(cron); + + const userProfileAnalyticsHistory = await con + .getRepository(UserProfileAnalyticsHistory) + .find({ + select: ['id', 'uniqueVisitors', 'date'], + order: { + updatedAt: 'ASC', + }, + }); + + expect(userProfileAnalyticsHistory.length).toBe( + userProfileAnalyticsFixture.length, + ); + + userProfileAnalyticsHistory.forEach((item, index) => { + expect(item).toEqual({ + id: userIds[index], + date, + uniqueVisitors: userProfileAnalyticsFixture[index].uniqueVisitors + 50, + } as UserProfileAnalyticsHistory); + }); + }); +}); diff --git a/__tests__/fixture/userProfileAnalytics.ts b/__tests__/fixture/userProfileAnalytics.ts new file mode 100644 index 0000000000..f26b9143d9 --- /dev/null +++ b/__tests__/fixture/userProfileAnalytics.ts @@ -0,0 +1,9 @@ +// fixture is without id and dates, you can inject them yourself in the test + +export const userProfileAnalyticsFixture = [ + { uniqueVisitors: 150 }, + { uniqueVisitors: 320 }, + { uniqueVisitors: 890 }, + { uniqueVisitors: 45 }, + { uniqueVisitors: 1250 }, +];