Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a757684
feat(user): add UTM parameters support in user creation and sign-up
Dobrunia Aug 6, 2025
ece94f6
Bump version up to 1.1.30
github-actions[bot] Aug 6, 2025
a4a7903
docs(user): clarify UTM parameter description in user creation and si…
Dobrunia Aug 6, 2025
9ab01e5
chore(deps): update @hawk.so/types to version 0.1.33 and enhance user…
Dobrunia Aug 6, 2025
33087ee
feat(user): validate and sanitize UTM parameters during user creation
Dobrunia Aug 6, 2025
14dc023
feat(analytics): enhance UTM parameter validation to include object t…
Dobrunia Aug 6, 2025
31ff97d
refactor(analytics): improve readability of UTM parameter validation …
Dobrunia Aug 6, 2025
e6a179f
refactor(utm): move UTM parameter validation and sanitization to a de…
Dobrunia Aug 9, 2025
c4aceeb
refactor(utm): add tests for UTM validation
Dobrunia Aug 9, 2025
bd6d64a
refactor(utm): extract UTM key validation and character checks into c…
Dobrunia Aug 12, 2025
02376ca
refactor(utm): define maximum UTM value length as a constant for bett…
Dobrunia Aug 12, 2025
e8114da
refactor(utm): enhance UTM parameter validation to return detailed re…
Dobrunia Aug 13, 2025
64b7122
chore(package): bump version to 1.1.32
Dobrunia Aug 13, 2025
6ff0955
Merge branch 'master' into feat/utm-tags-integration
Dobrunia Aug 13, 2025
c9e37d7
fix lint
Dobrunia Aug 13, 2025
4c6496e
fix lint
Dobrunia Aug 13, 2025
7febc31
refactor(utm): streamline UTM parameter sanitization and improve erro…
Dobrunia Aug 13, 2025
5d3ee35
test(utm): update UTM parameter validation tests to check for valid a…
Dobrunia Aug 13, 2025
e67f815
refactor(utm): simplify UTM parameter handling by removing sanitizati…
Dobrunia Aug 13, 2025
271e380
fix
Dobrunia Aug 13, 2025
34ea138
feat(users): add conditional UTM parameter inclusion in user data and…
Dobrunia Aug 13, 2025
61a95fd
test(utm): update validation tests to return undefined for null and n…
Dobrunia Aug 13, 2025
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.1.31",
"version": "1.1.32",
"main": "index.ts",
"license": "UNLICENSED",
"scripts": {
Expand Down
7 changes: 6 additions & 1 deletion src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,12 @@ export default class UserModel extends AbstractModel<UserDBScheme> implements Us
/**
* Saved bank cards for one-click payments
*/
public bankCards?: BankCard[]
public bankCards?: BankCard[];

/**
* UTM parameters from signup - Data form where user went to sign up. Used for analytics purposes
*/
public utm?: UserDBScheme['utm'];

/**
* Model's collection
Expand Down
16 changes: 13 additions & 3 deletions src/models/usersFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,26 @@ export default class UsersFactory extends AbstractModelFactory<UserDBScheme, Use
* Creates new user in DB and returns it
* @param email - user email
* @param password - user password
* @param utm - Data form where user went to sign up. Used for analytics purposes
*/
public async create(email: string, password?: string): Promise<UserModel> {
const generatedPassword = password || await UserModel.generatePassword();
public async create(
email: string,
password?: string,
utm?: UserDBScheme['utm']
): Promise<UserModel> {
const generatedPassword = password || (await UserModel.generatePassword());
const hashedPassword = await UserModel.hashPassword(generatedPassword);

const userData = {
const userData: Partial<UserDBScheme> = {
email,
password: hashedPassword,
notifications: UserModel.generateDefaultNotificationsSettings(email),
};

if (utm && Object.keys(utm).length > 0) {
userData.utm = utm;
}

const userId = (await this.collection.insertOne(userData)).insertedId;

const user = new UserModel({
Expand Down
8 changes: 6 additions & 2 deletions src/resolvers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { dateFromObjectId } from '../utils/dates';
import { UserDBScheme } from '@hawk.so/types';
import * as telegram from '../utils/telegram';
import { MongoError } from 'mongodb';
import { validateUtmParams } from '../utils/utm/utm';

/**
* See all types and fields here {@see ../typeDefs/user.graphql}
Expand All @@ -37,17 +38,20 @@ export default {
* Register user with provided email
* @param _obj - parent object (undefined for this resolver)
* @param email - user email
* @param utm - Data form where user went to sign up. Used for analytics purposes
* @param factories - factories for working with models
*/
async signUp(
_obj: undefined,
{ email }: {email: string},
{ email, utm }: { email: string; utm?: UserDBScheme['utm'] },
{ factories }: ResolverContextBase
): Promise<boolean | string> {
const validatedUtm = validateUtmParams(utm);

let user;

try {
user = await factories.usersFactory.create(email);
user = await factories.usersFactory.create(email, undefined, validatedUtm);

const password = user.generatedPassword!;

Expand Down
35 changes: 35 additions & 0 deletions src/typeDefs/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@ import { gql } from 'apollo-server-express';
import isE2E from '../utils/isE2E';

export default gql`
"""
UTM parameters input type
"""
input UtmInput {
"""
UTM source
"""
source: String

"""
UTM medium
"""
medium: String

"""
UTM campaign
"""
campaign: String

"""
UTM content
"""
content: String

"""
UTM term
"""
term: String
}

"""
Authentication token
"""
Expand Down Expand Up @@ -72,6 +102,11 @@ export default gql`
Registration email
"""
email: String! @validate(isEmail: true)

"""
UTM parameters
"""
utm: UtmInput
): ${isE2E ? 'String!' : 'Boolean!'}

"""
Expand Down
52 changes: 52 additions & 0 deletions src/utils/utm/utm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Valid UTM parameter keys
*/
const VALID_UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'];

/**
* Regular expression for valid UTM characters
* Allows: alphanumeric, spaces, hyphens, underscores, dots
*/
const VALID_UTM_CHARACTERS = /^[a-zA-Z0-9\s\-_.]+$/;

/**
* Maximum allowed length for UTM parameter values
*/
const MAX_UTM_VALUE_LENGTH = 50;

/**
* Validates and filters UTM parameters
* @param {Object} utm - UTM parameters to validate
* @returns {Object} - filtered valid UTM parameters
*/
export function validateUtmParams(utm: any): Record<string, string> | undefined {
if (!utm || typeof utm !== 'object' || Array.isArray(utm)) {
return undefined;
}

const result: Record<string, string> = {};

for (const [key, value] of Object.entries(utm)) {
// 1) Remove keys that are not VALID_UTM_KEYS
if (!VALID_UTM_KEYS.includes(key)) {
continue;
}

// 2) Check each condition separately
if (!value || typeof value !== 'string') {
continue;
}

if (value.length === 0 || value.length > MAX_UTM_VALUE_LENGTH) {
continue;
}

if (!VALID_UTM_CHARACTERS.test(value)) {
continue;
}

result[key] = value;
}

return result;
}
139 changes: 139 additions & 0 deletions test/utils/utm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { validateUtmParams } from '../../src/utils/utm/utm';

describe('UTM Utils', () => {
describe('validateUtmParams', () => {
it('should return undefined for undefined or null utm', () => {
expect(validateUtmParams(undefined)).toBeUndefined();
expect(validateUtmParams(null as any)).toBeUndefined();
});

it('should return empty object for empty object', () => {
expect(validateUtmParams({})).toEqual({});
});

it('should return undefined for non-object types', () => {
expect(validateUtmParams('string' as any)).toBeUndefined();
expect(validateUtmParams(123 as any)).toBeUndefined();
expect(validateUtmParams(true as any)).toBeUndefined();
expect(validateUtmParams([] as any)).toBeUndefined();
});

it('should filter out invalid UTM keys', () => {
const result1 = validateUtmParams({ invalidKey: 'value' } as any);
expect(result1).toEqual({});

const result2 = validateUtmParams({ source: 'google', invalidKey: 'value' } as any);
expect(result2).toEqual({ source: 'google' });
});

it('should return valid UTM parameters', () => {
const result1 = validateUtmParams({ source: 'google' });
expect(result1).toEqual({ source: 'google' });

const result2 = validateUtmParams({ medium: 'cpc' });
expect(result2).toEqual({ medium: 'cpc' });
});

it('should validate multiple UTM keys correctly', () => {
const validUtm = {
source: 'google',
medium: 'cpc',
campaign: 'spring_2025_launch',
content: 'ad_variant_a',
term: 'error_tracker',
};
const result = validateUtmParams(validUtm);
expect(result).toEqual(validUtm);
});

it('should filter out non-string values', () => {
const result1 = validateUtmParams({ source: 123 } as any);
expect(result1).toEqual({});

const result2 = validateUtmParams({ source: 'google', medium: true } as any);
expect(result2).toEqual({ source: 'google' });
});

it('should filter out empty string values', () => {
const result = validateUtmParams({ source: '' });
expect(result).toEqual({});
});

it('should filter out values that are too long', () => {
const longValue = 'a'.repeat(51);
const result = validateUtmParams({ source: longValue });
expect(result).toEqual({});
});

it('should accept values at maximum length', () => {
const maxLengthValue = 'a'.repeat(50);
const result = validateUtmParams({ source: maxLengthValue });
expect(result).toEqual({ source: maxLengthValue });
});

it('should filter out values with invalid characters', () => {
const result1 = validateUtmParams({ source: 'google@example' });
expect(result1).toEqual({});

const result2 = validateUtmParams({ source: 'google######' });
expect(result2).toEqual({});
});

it('should accept values with valid characters', () => {
const result = validateUtmParams({ source: 'google-ads' });
expect(result).toEqual({ source: 'google-ads' });

const result2 = validateUtmParams({
source: 'google_ads',
medium: 'cpc-campaign',
campaign: 'spring.2025',
content: 'Ad Variant 123',
term: 'error tracker',
});
expect(result2).toEqual({
source: 'google_ads',
medium: 'cpc-campaign',
campaign: 'spring.2025',
content: 'Ad Variant 123',
term: 'error tracker',
});
});

it('should handle mixed valid and invalid keys', () => {
const input = {
source: 'google',
medium: 'invalid@chars',
campaign: 'valid_campaign',
invalidKey: 'value',
} as any;
const result = validateUtmParams(input);
expect(result).toEqual({ source: 'google', campaign: 'valid_campaign' });
});

it('should filter out undefined and null values', () => {
const result = validateUtmParams({
source: 'google',
medium: undefined,
campaign: null,
} as any);
expect(result).toEqual({ source: 'google' });
});

it('should validate each parameter independently', () => {
const input = {
source: '######', // invalid chars
medium: 'cpc', // valid
campaign: 'spring_2025_launch', // valid
content: 'ad_variant_a', // valid
term: 'error_tracker', // valid
};
const result = validateUtmParams(input);
expect(result).toEqual({
medium: 'cpc',
campaign: 'spring_2025_launch',
content: 'ad_variant_a',
term: 'error_tracker',
});
});
});
});
Loading