Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .infra/crons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ export const crons: Cron[] = [
name: 'post-analytics-history-day-clickhouse',
schedule: '3-59/5 * * * *',
},
{
name: 'user-profile-analytics-clickhouse',
schedule: '7 */1 * * *',
},
{
name: 'user-profile-analytics-history-clickhouse',
schedule: '15 */1 * * *',
},
{
name: 'clean-zombie-opportunities',
schedule: '30 6 * * *',
Expand Down
189 changes: 188 additions & 1 deletion __tests__/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -178,6 +180,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', () => ({
Expand Down Expand Up @@ -216,7 +219,7 @@ beforeAll(async () => {
loggedUser,
undefined,
undefined,
undefined,
isTeamMember,
isPlus,
'US',
),
Expand All @@ -230,6 +233,7 @@ const now = new Date();
beforeEach(async () => {
loggedUser = null;
isPlus = false;
isTeamMember = false;
nock.cleanAll();
jest.clearAllMocks();

Expand Down Expand Up @@ -7656,3 +7660,186 @@ describe('mutation clearResume', () => {
).toEqual(0);
});
});

describe('query userProfileAnalytics', () => {
const QUERY = `
query UserProfileAnalytics($userId: ID!) {
userProfileAnalytics(userId: $userId) {
id
uniqueVisitors
updatedAt
}
}
`;

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';

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 not found error when no analytics record exists', () => {
loggedUser = '1';

return testQueryErrorCode(
client,
{ query: QUERY, variables: { userId: '1' } },
'NOT_FOUND',
);
});
});

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 not allow unauthenticated users', () =>
testQueryErrorCode(
client,
{ query: QUERY, variables: { userId: '1' } },
'UNAUTHENTICATED',
));

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-15T00:00:00.000Z',
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-15T00:00:00.000Z',
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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
CREATE TABLE IF NOT EXISTS api.user_profile_analytics
(
profile_id String,
created_at SimpleAggregateFunction(max, DateTime64(3)),
unique_visitors AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY profile_id;

CREATE TABLE IF NOT EXISTS api.user_profile_analytics_history
(
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, profile_id);

-- MV for main aggregation (all-time totals)
CREATE MATERIALIZED VIEW IF NOT EXISTS api.user_profile_analytics_mv
TO api.user_profile_analytics
AS
SELECT
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 IN ('profile view')
AND target_id IS NOT NULL
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 IF NOT EXISTS api.user_profile_analytics_history_mv
TO api.user_profile_analytics_history
AS
SELECT
target_id AS profile_id,
toDate(server_timestamp) AS date,
uniqStateIf(user_id, event_name = 'profile view') AS unique_visitors,
max(server_timestamp) AS created_at
FROM events.raw_events
WHERE event_name IN ('profile view')
AND target_id IS NOT NULL
AND server_timestamp > '2026-01-23 11:04:00'
GROUP BY date, target_id
SETTINGS materialized_views_ignore_errors = 1;
13 changes: 13 additions & 0 deletions src/common/schema/userProfileAnalytics.ts
Original file line number Diff line number Diff line change
@@ -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')),
});
20 changes: 20 additions & 0 deletions src/common/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,23 @@ export const checkCoresAccess = async ({

return checkUserCoresAccess({ user, requiredRole });
};

export const hasUserProfileAnalyticsPermissions = ({
ctx,
userId,
}: {
ctx: AuthContext;
userId: string;
}): boolean => {
const { userId: requesterId, isTeamMember } = ctx;

if (isTeamMember) {
return true;
}

if (!requesterId) {
return false;
}

return requesterId === userId;
};
4 changes: 4 additions & 0 deletions src/cron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import expireSuperAgentTrial from './expireSuperAgentTrial';
Expand Down Expand Up @@ -48,6 +50,8 @@ export const crons: Cron[] = [
cleanStaleUserTransactions,
postAnalyticsClickhouseCron,
postAnalyticsHistoryDayClickhouseCron,
userProfileAnalyticsClickhouseCron,
userProfileAnalyticsHistoryClickhouseCron,
cleanZombieOpportunities,
userProfileUpdatedSync,
expireSuperAgentTrial,
Expand Down
Loading
Loading