Skip to content

Commit 4c58121

Browse files
committed
Merge branch 'feat/utm-tags-integration' into stage
2 parents 920b9a4 + 61a95fd commit 4c58121

File tree

12 files changed

+804
-26
lines changed

12 files changed

+804
-26
lines changed

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.1.29",
3+
"version": "1.1.32",
44
"main": "index.ts",
55
"license": "UNLICENSED",
66
"scripts": {
@@ -37,13 +37,16 @@
3737
"@graphql-tools/schema": "^8.5.1",
3838
"@graphql-tools/utils": "^8.9.0",
3939
"@hawk.so/nodejs": "^3.1.1",
40-
"@hawk.so/types": "^0.1.31",
40+
"@hawk.so/types": "^0.1.33",
41+
"@n1ru4l/json-patch-plus": "^0.2.0",
4142
"@types/amqp-connection-manager": "^2.0.4",
4243
"@types/bson": "^4.0.5",
4344
"@types/debug": "^4.1.5",
4445
"@types/escape-html": "^1.0.0",
4546
"@types/graphql-upload": "^8.0.11",
4647
"@types/jsonwebtoken": "^8.3.5",
48+
"@types/lodash.clonedeep": "^4.5.9",
49+
"@types/lodash.mergewith": "^4.6.9",
4750
"@types/mime-types": "^2.1.0",
4851
"@types/mongodb": "^3.6.20",
4952
"@types/node": "^16.11.46",
@@ -70,6 +73,8 @@
7073
"graphql-upload": "^13",
7174
"jsonwebtoken": "^8.5.1",
7275
"lodash": "^4.17.15",
76+
"lodash.clonedeep": "^4.5.0",
77+
"lodash.mergewith": "^4.6.2",
7378
"migrate-mongo": "^7.0.1",
7479
"mime-types": "^2.1.25",
7580
"mongodb": "^3.7.3",

src/billing/cloudpayments.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import { ClientApi, ClientService, CustomerReceiptItem, ReceiptApi, ReceiptTypes
4545

4646
const PENNY_MULTIPLIER = 100;
4747

48-
4948
/**
5049
* Class for describing the logic of payment routes
5150
*/
@@ -89,7 +88,6 @@ export default class CloudPaymentsWebhooks {
8988
return router;
9089
}
9190

92-
9391
/**
9492
* Generates invoice id for payment
9593
*

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/billingNew.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export default {
113113

114114
// Calculate next payment date
115115
const lastChargeDate = workspace.lastChargeDate ? new Date(workspace.lastChargeDate) : now;
116-
let nextPaymentDate = isCardLinkOperation ? new Date(lastChargeDate) : new Date(now);
116+
const nextPaymentDate = isCardLinkOperation ? new Date(lastChargeDate) : new Date(now);
117117

118118
if (workspace.isDebug) {
119119
nextPaymentDate.setDate(nextPaymentDate.getDate() + 1);
@@ -123,18 +123,18 @@ export default {
123123

124124
const checksumData = isCardLinkOperation
125125
? {
126-
isCardLinkOperation: true as const,
127-
workspaceId: workspace._id.toString(),
128-
userId: user.id,
129-
nextPaymentDate: nextPaymentDate.toISOString(),
130-
}
126+
isCardLinkOperation: true as const,
127+
workspaceId: workspace._id.toString(),
128+
userId: user.id,
129+
nextPaymentDate: nextPaymentDate.toISOString(),
130+
}
131131
: {
132-
workspaceId: workspace._id.toString(),
133-
userId: user.id,
134-
tariffPlanId: plan._id.toString(),
135-
shouldSaveCard: Boolean(shouldSaveCard),
136-
nextPaymentDate: nextPaymentDate.toISOString(),
137-
};
132+
workspaceId: workspace._id.toString(),
133+
userId: user.id,
134+
tariffPlanId: plan._id.toString(),
135+
shouldSaveCard: Boolean(shouldSaveCard),
136+
nextPaymentDate: nextPaymentDate.toISOString(),
137+
};
138138

139139
const checksum = await checksumService.generateChecksum(checksumData);
140140

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/merge.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import mergeWith from 'lodash.mergewith';
2+
import cloneDeep from 'lodash.clonedeep';
3+
import { patch } from '@n1ru4l/json-patch-plus';
4+
import { GroupedEventDBScheme, RepetitionDBScheme } from '@hawk.so/types';
5+
6+
/**
7+
* One of the features of the events is that their repetition is the difference
8+
* between the original, which greatly optimizes storage. So we need to restore
9+
* the original repetition payload using the very first event and its difference
10+
* between its repetition
11+
*
12+
* @deprecated remove after 6 september 2025
13+
* @param originalEvent - the very first event we received
14+
* @param repetition - the difference with its repetition, for the repetition we want to display
15+
* @returns fully assembled payload of the current repetition
16+
*/
17+
export function repetitionAssembler(originalEvent: GroupedEventDBScheme['payload'], repetition: GroupedEventDBScheme['payload']): GroupedEventDBScheme['payload'] {
18+
const customizer = (originalParam: any, repetitionParam: any): any => {
19+
if (repetitionParam === null) {
20+
return originalParam;
21+
}
22+
23+
if (typeof repetitionParam === 'object' && typeof originalParam === 'object') {
24+
/**
25+
* If original event has null but repetition has some value, we need to return repetition value
26+
*/
27+
if (originalParam === null) {
28+
return repetitionParam;
29+
/**
30+
* Otherwise, we need to recursively merge original and repetition values
31+
*/
32+
} else {
33+
return repetitionAssembler(originalParam, repetitionParam);
34+
}
35+
}
36+
37+
return repetitionParam;
38+
};
39+
40+
return mergeWith(cloneDeep(originalEvent), cloneDeep(repetition), customizer);
41+
}
42+
43+
/**
44+
* Parse addons and context fields from string to object, in db it stores as string
45+
*
46+
* @param payload - the payload of the event
47+
* @param field - the field to parse, can be 'addons' or 'context'
48+
* @returns the payload with parsed field
49+
*/
50+
function parsePayloadField(payload: GroupedEventDBScheme['payload'], field: 'addons' | 'context') {
51+
if (payload && payload[field] && typeof payload[field] === 'string') {
52+
payload[field] = JSON.parse(payload[field] as string);
53+
}
54+
55+
return payload;
56+
}
57+
58+
/**
59+
* Stringify addons and context fields from object to string, in db it stores as string
60+
*
61+
* @param payload - the payload of the event
62+
* @param field - the field to stringify, can be 'addons' or 'context'
63+
* @returns the payload with stringified field
64+
*/
65+
function stringifyPayloadField(payload: GroupedEventDBScheme['payload'], field: 'addons' | 'context') {
66+
if (payload && payload[field]) {
67+
payload[field] = JSON.stringify(payload[field]);
68+
}
69+
70+
return payload;
71+
}
72+
73+
/**
74+
* Helps to merge original event and repetition due to delta format,
75+
* in case of old delta format, we need to patch the payload
76+
* in case of new delta format, we need to assemble the payload
77+
*
78+
* @param originalEvent {HawkEvent} - The original event
79+
* @param repetition {HawkEventRepetition} - The repetition to process
80+
* @returns {HawkEvent} Updated event with processed repetition payload
81+
*/
82+
export function composeFullRepetitionEvent(originalEvent: GroupedEventDBScheme, repetition: RepetitionDBScheme | undefined): GroupedEventDBScheme {
83+
/**
84+
* Make a deep copy of the original event, because we need to avoid mutating the original event
85+
*/
86+
const event = cloneDeep(originalEvent);
87+
88+
if (!repetition) {
89+
return event;
90+
}
91+
92+
/**
93+
* New delta format (repetition.delta is not null)
94+
*/
95+
if (repetition.delta) {
96+
/**
97+
* Parse addons and context fields from string to object before patching
98+
*/
99+
event.payload = parsePayloadField(event.payload, 'addons');
100+
event.payload = parsePayloadField(event.payload, 'context');
101+
102+
event.payload = patch({
103+
left: event.payload,
104+
delta: JSON.parse(repetition.delta),
105+
});
106+
107+
/**
108+
* Stringify addons and context fields from object to string after patching
109+
*/
110+
event.payload = stringifyPayloadField(event.payload, 'addons');
111+
event.payload = stringifyPayloadField(event.payload, 'context');
112+
113+
return event;
114+
}
115+
116+
/**
117+
* New delta format (repetition.payload is null) and repetition.delta is null (there is no delta between original and repetition)
118+
*/
119+
if (!repetition.payload) {
120+
return event;
121+
}
122+
123+
/**
124+
* Old delta format (repetition.payload is not null)
125+
* @todo remove after 6 september 2025
126+
*/
127+
event.payload = repetitionAssembler(event.payload, repetition.payload);
128+
129+
return event;
130+
}

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+
}

0 commit comments

Comments
 (0)