Skip to content
Open
Show file tree
Hide file tree
Changes from all 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: '*/5 * * * *',
},
{
name: 'user-profile-analytics-history-clickhouse',
schedule: '3-59/5 * * * *',
},
{
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 @@ -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', () => ({
Expand Down Expand Up @@ -206,7 +209,7 @@ beforeAll(async () => {
loggedUser,
undefined,
undefined,
undefined,
isTeamMember,
isPlus,
'US',
),
Expand All @@ -220,6 +223,7 @@ const now = new Date();
beforeEach(async () => {
loggedUser = null;
isPlus = false;
isTeamMember = false;
nock.cleanAll();
jest.clearAllMocks();

Expand Down Expand Up @@ -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 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 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;
DROP TABLE IF EXISTS api.user_profile_analytics;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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;

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;
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 @@ -233,3 +233,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';

Expand All @@ -47,6 +49,8 @@ export const crons: Cron[] = [
cleanStaleUserTransactions,
postAnalyticsClickhouseCron,
postAnalyticsHistoryDayClickhouseCron,
userProfileAnalyticsClickhouseCron,
userProfileAnalyticsHistoryClickhouseCron,
cleanZombieOpportunities,
userProfileUpdatedSync,
];
Loading
Loading