diff --git a/package.json b/package.json index 3bec10d2..1bf3ff42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.1.31", + "version": "1.1.32", "main": "index.ts", "license": "UNLICENSED", "scripts": { diff --git a/src/models/user.ts b/src/models/user.ts index b6e9cb8c..7ea2f065 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -133,7 +133,12 @@ export default class UserModel extends AbstractModel 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 diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index ffa7f249..f3c6ff70 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -62,16 +62,26 @@ export default class UsersFactory extends AbstractModelFactory { - const generatedPassword = password || await UserModel.generatePassword(); + public async create( + email: string, + password?: string, + utm?: UserDBScheme['utm'] + ): Promise { + const generatedPassword = password || (await UserModel.generatePassword()); const hashedPassword = await UserModel.hashPassword(generatedPassword); - const userData = { + const userData: Partial = { 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({ diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index bfe663c5..57a75e8c 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -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} @@ -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 { + 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!; diff --git a/src/typeDefs/user.ts b/src/typeDefs/user.ts index aa82cc4a..2d8aa25b 100644 --- a/src/typeDefs/user.ts +++ b/src/typeDefs/user.ts @@ -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 """ @@ -72,6 +102,11 @@ export default gql` Registration email """ email: String! @validate(isEmail: true) + + """ + UTM parameters + """ + utm: UtmInput ): ${isE2E ? 'String!' : 'Boolean!'} """ diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts new file mode 100644 index 00000000..c3845e01 --- /dev/null +++ b/src/utils/utm/utm.ts @@ -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 | undefined { + if (!utm || typeof utm !== 'object' || Array.isArray(utm)) { + return undefined; + } + + const result: Record = {}; + + 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; +} diff --git a/test/utils/utm.test.ts b/test/utils/utm.test.ts new file mode 100644 index 00000000..fffbdb21 --- /dev/null +++ b/test/utils/utm.test.ts @@ -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', + }); + }); + }); +});