Skip to content

Commit d6bcb65

Browse files
authored
Merge pull request #514 from codex-team/feat/utm-tags-integration
feat(user): add UTM parameters support in user creation and sign-up
2 parents 97abd38 + 61a95fd commit d6bcb65

File tree

7 files changed

+252
-7
lines changed

7 files changed

+252
-7
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.1.31",
3+
"version": "1.1.32",
44
"main": "index.ts",
55
"license": "UNLICENSED",
66
"scripts": {

src/models/user.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,12 @@ export default class UserModel extends AbstractModel<UserDBScheme> implements Us
133133
/**
134134
* Saved bank cards for one-click payments
135135
*/
136-
public bankCards?: BankCard[]
136+
public bankCards?: BankCard[];
137+
138+
/**
139+
* UTM parameters from signup - Data form where user went to sign up. Used for analytics purposes
140+
*/
141+
public utm?: UserDBScheme['utm'];
137142

138143
/**
139144
* Model's collection

src/models/usersFactory.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,26 @@ export default class UsersFactory extends AbstractModelFactory<UserDBScheme, Use
6262
* Creates new user in DB and returns it
6363
* @param email - user email
6464
* @param password - user password
65+
* @param utm - Data form where user went to sign up. Used for analytics purposes
6566
*/
66-
public async create(email: string, password?: string): Promise<UserModel> {
67-
const generatedPassword = password || await UserModel.generatePassword();
67+
public async create(
68+
email: string,
69+
password?: string,
70+
utm?: UserDBScheme['utm']
71+
): Promise<UserModel> {
72+
const generatedPassword = password || (await UserModel.generatePassword());
6873
const hashedPassword = await UserModel.hashPassword(generatedPassword);
6974

70-
const userData = {
75+
const userData: Partial<UserDBScheme> = {
7176
email,
7277
password: hashedPassword,
7378
notifications: UserModel.generateDefaultNotificationsSettings(email),
7479
};
80+
81+
if (utm && Object.keys(utm).length > 0) {
82+
userData.utm = utm;
83+
}
84+
7585
const userId = (await this.collection.insertOne(userData)).insertedId;
7686

7787
const user = new UserModel({

src/resolvers/user.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { dateFromObjectId } from '../utils/dates';
1111
import { UserDBScheme } from '@hawk.so/types';
1212
import * as telegram from '../utils/telegram';
1313
import { MongoError } from 'mongodb';
14+
import { validateUtmParams } from '../utils/utm/utm';
1415

1516
/**
1617
* See all types and fields here {@see ../typeDefs/user.graphql}
@@ -37,17 +38,20 @@ export default {
3738
* Register user with provided email
3839
* @param _obj - parent object (undefined for this resolver)
3940
* @param email - user email
41+
* @param utm - Data form where user went to sign up. Used for analytics purposes
4042
* @param factories - factories for working with models
4143
*/
4244
async signUp(
4345
_obj: undefined,
44-
{ email }: {email: string},
46+
{ email, utm }: { email: string; utm?: UserDBScheme['utm'] },
4547
{ factories }: ResolverContextBase
4648
): Promise<boolean | string> {
49+
const validatedUtm = validateUtmParams(utm);
50+
4751
let user;
4852

4953
try {
50-
user = await factories.usersFactory.create(email);
54+
user = await factories.usersFactory.create(email, undefined, validatedUtm);
5155

5256
const password = user.generatedPassword!;
5357

src/typeDefs/user.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,36 @@ import { gql } from 'apollo-server-express';
22
import isE2E from '../utils/isE2E';
33

44
export default gql`
5+
"""
6+
UTM parameters input type
7+
"""
8+
input UtmInput {
9+
"""
10+
UTM source
11+
"""
12+
source: String
13+
14+
"""
15+
UTM medium
16+
"""
17+
medium: String
18+
19+
"""
20+
UTM campaign
21+
"""
22+
campaign: String
23+
24+
"""
25+
UTM content
26+
"""
27+
content: String
28+
29+
"""
30+
UTM term
31+
"""
32+
term: String
33+
}
34+
535
"""
636
Authentication token
737
"""
@@ -72,6 +102,11 @@ export default gql`
72102
Registration email
73103
"""
74104
email: String! @validate(isEmail: true)
105+
106+
"""
107+
UTM parameters
108+
"""
109+
utm: UtmInput
75110
): ${isE2E ? 'String!' : 'Boolean!'}
76111
77112
"""

src/utils/utm/utm.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Valid UTM parameter keys
3+
*/
4+
const VALID_UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'];
5+
6+
/**
7+
* Regular expression for valid UTM characters
8+
* Allows: alphanumeric, spaces, hyphens, underscores, dots
9+
*/
10+
const VALID_UTM_CHARACTERS = /^[a-zA-Z0-9\s\-_.]+$/;
11+
12+
/**
13+
* Maximum allowed length for UTM parameter values
14+
*/
15+
const MAX_UTM_VALUE_LENGTH = 50;
16+
17+
/**
18+
* Validates and filters UTM parameters
19+
* @param {Object} utm - UTM parameters to validate
20+
* @returns {Object} - filtered valid UTM parameters
21+
*/
22+
export function validateUtmParams(utm: any): Record<string, string> | undefined {
23+
if (!utm || typeof utm !== 'object' || Array.isArray(utm)) {
24+
return undefined;
25+
}
26+
27+
const result: Record<string, string> = {};
28+
29+
for (const [key, value] of Object.entries(utm)) {
30+
// 1) Remove keys that are not VALID_UTM_KEYS
31+
if (!VALID_UTM_KEYS.includes(key)) {
32+
continue;
33+
}
34+
35+
// 2) Check each condition separately
36+
if (!value || typeof value !== 'string') {
37+
continue;
38+
}
39+
40+
if (value.length === 0 || value.length > MAX_UTM_VALUE_LENGTH) {
41+
continue;
42+
}
43+
44+
if (!VALID_UTM_CHARACTERS.test(value)) {
45+
continue;
46+
}
47+
48+
result[key] = value;
49+
}
50+
51+
return result;
52+
}

test/utils/utm.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { validateUtmParams } from '../../src/utils/utm/utm';
2+
3+
describe('UTM Utils', () => {
4+
describe('validateUtmParams', () => {
5+
it('should return undefined for undefined or null utm', () => {
6+
expect(validateUtmParams(undefined)).toBeUndefined();
7+
expect(validateUtmParams(null as any)).toBeUndefined();
8+
});
9+
10+
it('should return empty object for empty object', () => {
11+
expect(validateUtmParams({})).toEqual({});
12+
});
13+
14+
it('should return undefined for non-object types', () => {
15+
expect(validateUtmParams('string' as any)).toBeUndefined();
16+
expect(validateUtmParams(123 as any)).toBeUndefined();
17+
expect(validateUtmParams(true as any)).toBeUndefined();
18+
expect(validateUtmParams([] as any)).toBeUndefined();
19+
});
20+
21+
it('should filter out invalid UTM keys', () => {
22+
const result1 = validateUtmParams({ invalidKey: 'value' } as any);
23+
expect(result1).toEqual({});
24+
25+
const result2 = validateUtmParams({ source: 'google', invalidKey: 'value' } as any);
26+
expect(result2).toEqual({ source: 'google' });
27+
});
28+
29+
it('should return valid UTM parameters', () => {
30+
const result1 = validateUtmParams({ source: 'google' });
31+
expect(result1).toEqual({ source: 'google' });
32+
33+
const result2 = validateUtmParams({ medium: 'cpc' });
34+
expect(result2).toEqual({ medium: 'cpc' });
35+
});
36+
37+
it('should validate multiple UTM keys correctly', () => {
38+
const validUtm = {
39+
source: 'google',
40+
medium: 'cpc',
41+
campaign: 'spring_2025_launch',
42+
content: 'ad_variant_a',
43+
term: 'error_tracker',
44+
};
45+
const result = validateUtmParams(validUtm);
46+
expect(result).toEqual(validUtm);
47+
});
48+
49+
it('should filter out non-string values', () => {
50+
const result1 = validateUtmParams({ source: 123 } as any);
51+
expect(result1).toEqual({});
52+
53+
const result2 = validateUtmParams({ source: 'google', medium: true } as any);
54+
expect(result2).toEqual({ source: 'google' });
55+
});
56+
57+
it('should filter out empty string values', () => {
58+
const result = validateUtmParams({ source: '' });
59+
expect(result).toEqual({});
60+
});
61+
62+
it('should filter out values that are too long', () => {
63+
const longValue = 'a'.repeat(51);
64+
const result = validateUtmParams({ source: longValue });
65+
expect(result).toEqual({});
66+
});
67+
68+
it('should accept values at maximum length', () => {
69+
const maxLengthValue = 'a'.repeat(50);
70+
const result = validateUtmParams({ source: maxLengthValue });
71+
expect(result).toEqual({ source: maxLengthValue });
72+
});
73+
74+
it('should filter out values with invalid characters', () => {
75+
const result1 = validateUtmParams({ source: 'google@example' });
76+
expect(result1).toEqual({});
77+
78+
const result2 = validateUtmParams({ source: 'google######' });
79+
expect(result2).toEqual({});
80+
});
81+
82+
it('should accept values with valid characters', () => {
83+
const result = validateUtmParams({ source: 'google-ads' });
84+
expect(result).toEqual({ source: 'google-ads' });
85+
86+
const result2 = validateUtmParams({
87+
source: 'google_ads',
88+
medium: 'cpc-campaign',
89+
campaign: 'spring.2025',
90+
content: 'Ad Variant 123',
91+
term: 'error tracker',
92+
});
93+
expect(result2).toEqual({
94+
source: 'google_ads',
95+
medium: 'cpc-campaign',
96+
campaign: 'spring.2025',
97+
content: 'Ad Variant 123',
98+
term: 'error tracker',
99+
});
100+
});
101+
102+
it('should handle mixed valid and invalid keys', () => {
103+
const input = {
104+
source: 'google',
105+
medium: 'invalid@chars',
106+
campaign: 'valid_campaign',
107+
invalidKey: 'value',
108+
} as any;
109+
const result = validateUtmParams(input);
110+
expect(result).toEqual({ source: 'google', campaign: 'valid_campaign' });
111+
});
112+
113+
it('should filter out undefined and null values', () => {
114+
const result = validateUtmParams({
115+
source: 'google',
116+
medium: undefined,
117+
campaign: null,
118+
} as any);
119+
expect(result).toEqual({ source: 'google' });
120+
});
121+
122+
it('should validate each parameter independently', () => {
123+
const input = {
124+
source: '######', // invalid chars
125+
medium: 'cpc', // valid
126+
campaign: 'spring_2025_launch', // valid
127+
content: 'ad_variant_a', // valid
128+
term: 'error_tracker', // valid
129+
};
130+
const result = validateUtmParams(input);
131+
expect(result).toEqual({
132+
medium: 'cpc',
133+
campaign: 'spring_2025_launch',
134+
content: 'ad_variant_a',
135+
term: 'error_tracker',
136+
});
137+
});
138+
});
139+
});

0 commit comments

Comments
 (0)