Hacktober apps fix (#15733)
- update dependencies in mailchimp, stripe and last email interaction apps - fix logic in mailchimp integration, now it's triggered by update of People records and allows for update Mailchimp records - fix logic in stripe integration, now properly reads data from webhook - update READMEs to make it more understandable to non-technical users
This commit is contained in:
Binary file not shown.
@@ -17,5 +17,5 @@ twenty app sync
|
||||
|
||||
## Flow
|
||||
- Checks if fields are created, if not, creates them on fly
|
||||
- Extracts the timedate of message and calculates the last interaction status
|
||||
- Extracts the datetime of message and calculates the last interaction status
|
||||
- Fetches all users and companies connected to them and updates their Last interaction and Interaction status fields
|
||||
@@ -9,13 +9,11 @@ const config: ApplicationConfig = {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: 'aae3f523-4c1f-4805-b3ee-afeb676c381e',
|
||||
isSecret: true,
|
||||
value: '',
|
||||
description: 'Required to send requests to Twenty',
|
||||
},
|
||||
TWENTY_API_URL: {
|
||||
universalIdentifier: '6d19bb04-45bb-46aa-a4e5-4a2682c7b19d',
|
||||
isSecret: false,
|
||||
value: '',
|
||||
description: 'Optional, defaults to cloud API URL',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2",
|
||||
"twenty-sdk": "^0.0.3"
|
||||
"twenty-sdk": "^0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
|
||||
+54
-70
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
|
||||
|
||||
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '';
|
||||
@@ -7,7 +6,6 @@ const TWENTY_URL =
|
||||
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
|
||||
? `${process.env.TWENTY_API_URL}/rest`
|
||||
: 'https://api.twenty.com/rest';
|
||||
const DELAY = 500;
|
||||
|
||||
const create_last_interaction = (id: string) => {
|
||||
return {
|
||||
@@ -100,6 +98,53 @@ const interactionData = (date: string, status: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const updateInteractionStatus = async (objectName: string, id: string, messageDate: string, status: string) => {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
url: `${TWENTY_URL}/${objectName}/${id}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
lastInteraction: messageDate,
|
||||
interactionStatus: status
|
||||
}
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
if (response.status === 200) {
|
||||
console.log('Successfully updated company last interaction field');
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRelatedCompanyId = async (id: string) => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/people/${id}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const req = await axios.request(options);
|
||||
if (req.status === 200 && req.data.person.companyId !== null && req.data.person.companyId !== undefined) {
|
||||
return req.data.person.companyId;
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const main = async (params: {
|
||||
properties: Record<string, any>;
|
||||
recordId: string;
|
||||
@@ -147,7 +192,6 @@ export const main = async (params: {
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created company last interaction field');
|
||||
}
|
||||
await setTimeout(DELAY);
|
||||
}
|
||||
if (company_interaction_status === undefined) {
|
||||
const response2 = await axios.request(
|
||||
@@ -156,7 +200,6 @@ export const main = async (params: {
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created company interaction status field');
|
||||
}
|
||||
await setTimeout(DELAY);
|
||||
}
|
||||
if (person_last_interaction === undefined) {
|
||||
const response2 = await axios.request(
|
||||
@@ -165,7 +208,6 @@ export const main = async (params: {
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created person last interaction field');
|
||||
}
|
||||
await setTimeout(DELAY);
|
||||
}
|
||||
if (person_interaction_status === undefined) {
|
||||
const response2 = await axios.request(
|
||||
@@ -174,11 +216,10 @@ export const main = async (params: {
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created person interaction status field');
|
||||
}
|
||||
await setTimeout(DELAY);
|
||||
}
|
||||
|
||||
// Extract the timestamp of message
|
||||
const messageDate = properties.receivedAt;
|
||||
const messageDate = properties.after.receivedAt;
|
||||
const interactionStatus = calculateStatus(messageDate);
|
||||
|
||||
// Get the details of person and related company
|
||||
@@ -190,8 +231,7 @@ export const main = async (params: {
|
||||
},
|
||||
};
|
||||
const messageDetails = await axios.request(messageOptions);
|
||||
await setTimeout(DELAY);
|
||||
const peopleIds = [];
|
||||
const peopleIds: string[] = [];
|
||||
for (const participant of messageDetails.data.messages
|
||||
.messageParticipants) {
|
||||
peopleIds.push(participant.personId);
|
||||
@@ -199,78 +239,22 @@ export const main = async (params: {
|
||||
|
||||
const companiesIds = [];
|
||||
for (const id of peopleIds) {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/people/${id}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const req = await axios.request(options);
|
||||
companiesIds.push(req.data.person.companyId);
|
||||
await setTimeout(DELAY);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
companiesIds.push(await fetchRelatedCompanyId(id));
|
||||
}
|
||||
// Update the field value depending on the timestamp
|
||||
for (const id of peopleIds) {
|
||||
const peopleOptions = {
|
||||
method: 'PATCH',
|
||||
url: `${TWENTY_URL}/people/${id}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: interactionData(messageDate, interactionStatus),
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
if (response.status === 200) {
|
||||
console.log('Successfully updated company last interaction field');
|
||||
await setTimeout(DELAY);
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await updateInteractionStatus("people", id, messageDate, interactionStatus);
|
||||
}
|
||||
|
||||
for (const id of companiesIds) {
|
||||
const companiesOptions = {
|
||||
method: 'PATCH',
|
||||
url: `${TWENTY_URL}/companies/${id}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: interactionData(messageDate, interactionStatus),
|
||||
};
|
||||
try {
|
||||
const req = await axios.request(companiesOptions);
|
||||
if (req.status === 200) {
|
||||
console.log(`Successfully updated company with ID ${id}`);
|
||||
await setTimeout(DELAY);
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await updateInteractionStatus("companies", id, messageDate, interactionStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(error);
|
||||
console.error(error.message);
|
||||
return {};
|
||||
}
|
||||
console.log(error);
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,13 +5,14 @@ __metadata:
|
||||
version: 8
|
||||
cacheKey: 10c0
|
||||
|
||||
"Last email interaction@workspace:.":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "Last email interaction@workspace:."
|
||||
"@types/node@npm:^24.7.2":
|
||||
version: 24.10.0
|
||||
resolution: "@types/node@npm:24.10.0"
|
||||
dependencies:
|
||||
axios: "npm:^1.12.2"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
undici-types: "npm:~7.16.0"
|
||||
checksum: 10c0/f82ed7194e16f5590ef7afdc20c6d09068c76d50278b485ede8f0c5749683536e3064ffa8def8db76915196afb3724b854aa5723c64d6571b890b14492943b46
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
@@ -217,6 +218,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"last-email-interaction@workspace:.":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "last-email-interaction@workspace:."
|
||||
dependencies:
|
||||
"@types/node": "npm:^24.7.2"
|
||||
axios: "npm:^1.12.2"
|
||||
twenty-sdk: "npm:^0.0.4"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"math-intrinsics@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "math-intrinsics@npm:1.1.0"
|
||||
@@ -246,3 +257,17 @@ __metadata:
|
||||
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"twenty-sdk@npm:^0.0.4":
|
||||
version: 0.0.4
|
||||
resolution: "twenty-sdk@npm:0.0.4"
|
||||
checksum: 10c0/550f1d85bf0701396c9dd2d4c6bc55ba1b067fce13636f8540eec60ab6a4257c6d7cd86cb3f62e0974bf99467bc31270d92b17b9681a1b7a6281b7ef97224080
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~7.16.0":
|
||||
version: 7.16.0
|
||||
resolution: "undici-types@npm:7.16.0"
|
||||
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
Binary file not shown.
@@ -5,6 +5,7 @@ Synchronizing contacts between Twenty and Mailchimp
|
||||
## Requirements
|
||||
- twenty-cli `npm install -g twenty-cli`
|
||||
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
|
||||
- Mailchimp API key - Mailchimp > avatar in top right corner > Profile > Extras > API keys
|
||||
|
||||
## Setup
|
||||
1. Add app to your workspace
|
||||
@@ -13,13 +14,20 @@ twenty auth login
|
||||
cd mailchimp-synchronizer
|
||||
twenty app sync
|
||||
```
|
||||
|
||||
2. Go to Settings > Integrations > Mailchimp synchronizer > Settings and add required variables
|
||||
|
||||
## Flow
|
||||
- Check if required variables are set, if not, exit
|
||||
- Validate data based on set constraints
|
||||
- If all constraints are checked, send a request to Mailchimp with new contact
|
||||
- Validate data based on set constraints, if data doesn't match constraints, exit
|
||||
- Check if person already exists in Mailchimp:
|
||||
- if yes, check if UPDATE_PERSON is set to true
|
||||
- if UPDATE_PERSON is true, check if Twenty record is the same as Mailchimp record
|
||||
- if they're the same, exit
|
||||
- if not, update
|
||||
- if UPDATE_PERSON is false, exit
|
||||
- if person doesn't exist in Mailchimp, send a request to Mailchimp with new contact
|
||||
|
||||
## Note
|
||||
- SMS support is experimental and may cause errors
|
||||
- SMS support is experimental and may cause errors
|
||||
- constraints are directly responsible for sent data so if e.g. you want to have a company name in
|
||||
Mailchimp, you have to set IS_COMPANY_CONSTRAINT to true
|
||||
+16
-12
@@ -3,63 +3,67 @@ import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: '1eadac4e-db9f-4cce-b20b-de75f41e34dc',
|
||||
displayName: 'Mailchimp synchronizer',
|
||||
description: '',
|
||||
description: 'Synchronizes Twenty contacts in Mailchimp',
|
||||
icon: "IconMailFast",
|
||||
applicationVariables: {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: '0af17af3-66b8-40cf-b6e2-6a29a1da5464',
|
||||
isSecret: true,
|
||||
value: '',
|
||||
description: 'Required to send requests to Twenty',
|
||||
},
|
||||
TWENTY_API_URL: {
|
||||
universalIdentifier: '12949c1c-aed7-4a9f-bd06-9fd15f0bfa63',
|
||||
value: '',
|
||||
isSecret: false,
|
||||
description: 'Optional, defaults to cloud API URL',
|
||||
},
|
||||
MAILCHIMP_API_KEY: {
|
||||
universalIdentifier: 'f10d4e8a-8055-4eb2-b9ad-efd69d43b1f0',
|
||||
isSecret: true,
|
||||
value: '',
|
||||
description: 'Required to send requests to Mailchimp',
|
||||
},
|
||||
MAILCHIMP_URL: {
|
||||
MAILCHIMP_SERVER_PREFIX: {
|
||||
universalIdentifier: '6c8b6ac9-dd45-4f0b-a397-c4a38edccfd9',
|
||||
value: '',
|
||||
isSecret: false,
|
||||
description: 'Required to send requests to Mailchimp (it\'s found in url, e.g. https://us9.admin.mailchimp.com > us9 is prefix)',
|
||||
},
|
||||
MAILCHIMP_AUDIENCE_ID: {
|
||||
universalIdentifier: '5492f06f-bb29-4c93-9436-b4736a396376',
|
||||
isSecret: false,
|
||||
description: 'Required to send requests to Mailchimp',
|
||||
},
|
||||
IS_EMAIL_CONSTRAINT: {
|
||||
universalIdentifier: '62626c57-470f-4866-be1e-5b4d7ec09f9f',
|
||||
isSecret: false,
|
||||
value: 'false',
|
||||
description:
|
||||
'Set to true if you want to add additional constraint (default is false)',
|
||||
},
|
||||
IS_PHONE_CONSTRAINT: {
|
||||
universalIdentifier: 'fac8ec5b-dade-46bf-b938-3dfdef0aa298',
|
||||
isSecret: false,
|
||||
value: 'false',
|
||||
description:
|
||||
'Set to true if you want to add additional constraint (default is false)',
|
||||
},
|
||||
IS_COMPANY_CONSTRAINT: {
|
||||
universalIdentifier: '9ffd8e76-4ab2-42f9-8549-3622a5ae2343',
|
||||
isSecret: false,
|
||||
value: 'false',
|
||||
description:
|
||||
'Set to true if you want to add additional constraint (default is false)',
|
||||
},
|
||||
IS_ADDRESS_CONSTRAINT: {
|
||||
universalIdentifier: '4b899eb6-517e-4afd-bbf8-88097900ea42',
|
||||
isSecret: false,
|
||||
value: 'false',
|
||||
description:
|
||||
'Set to true if you want to add additional constraint (default is false)',
|
||||
},
|
||||
UPDATE_PERSON: {
|
||||
universalIdentifier: '9d753e1e-4408-40ca-b0f0-5c7e8625c2aa',
|
||||
isSecret: true,
|
||||
value: 'false',
|
||||
description: 'Set to true if you want to update record if it exists',
|
||||
},
|
||||
MAILCHIMP_AUDIENCE_ID: {
|
||||
universalIdentifier: '5492f06f-bb29-4c93-9436-b4736a396376',
|
||||
value: '',
|
||||
description: 'Required to send requests to Mailchimp',
|
||||
description: 'Set to true if you want to update record in Mailchimp if it exists',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"twenty-sdk": "^0.0.3"
|
||||
"axios": "^1.13.2",
|
||||
"twenty-sdk": "^0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
|
||||
+262
-88
@@ -7,16 +7,19 @@ const TWENTY_API_URL: string =
|
||||
: 'https://api.twenty.com/rest';
|
||||
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
|
||||
const MAILCHIMP_API_URL: string =
|
||||
process.env.MAILCHIMP_URL !== '' && process.env.MAILCHIMP_URL !== undefined
|
||||
? `https://${process.env.MAILCHIMP_URL}.api.mailchimp.com/3.0/`
|
||||
process.env.MAILCHIMP_SERVER_PREFIX !== '' &&
|
||||
process.env.MAILCHIMP_SERVER_PREFIX !== undefined
|
||||
? `https://${process.env.MAILCHIMP_SERVER_PREFIX}.api.mailchimp.com/3.0/`
|
||||
: '';
|
||||
const MAILCHIMP_API_KEY: string = process.env.MAILCHIMP_API_KEY ?? '';
|
||||
const MAILCHIMP_AUDIENCE_ID: string = process.env.MAILCHIMP_AUDIENCE_ID ?? '';
|
||||
const IS_EMAIL_CONSTRAINT: boolean = process.env.IS_EMAIL_CONSTRAINT == 'true';
|
||||
const IS_COMPANY_CONSTRAINT: boolean = process.env.COMPANY_CONSTRAINT == 'true';
|
||||
const IS_PHONE_CONSTRAINT: boolean = process.env.IS_PHONE_CONSTRAINT == 'true';
|
||||
const IS_EMAIL_CONSTRAINT: boolean = process.env.IS_EMAIL_CONSTRAINT === 'true';
|
||||
const IS_COMPANY_CONSTRAINT: boolean =
|
||||
process.env.COMPANY_CONSTRAINT === 'true';
|
||||
const IS_PHONE_CONSTRAINT: boolean = process.env.IS_PHONE_CONSTRAINT === 'true';
|
||||
const IS_ADDRESS_CONSTRAINT: boolean =
|
||||
process.env.IS_ADDRESS_CONSTRAINT == 'true';
|
||||
process.env.IS_ADDRESS_CONSTRAINT === 'true';
|
||||
const UPDATE_PERSON: boolean = process.env.UPDATE_PERSON === 'true';
|
||||
|
||||
type mailchimpAddress = {
|
||||
street1: string;
|
||||
@@ -27,6 +30,29 @@ type mailchimpAddress = {
|
||||
country: string;
|
||||
};
|
||||
|
||||
type mailchimpRecord = {
|
||||
id?: string;
|
||||
email_channel?: {
|
||||
email: string;
|
||||
marketing_consent?: {
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
sms_channel?: {
|
||||
sms_phone: string;
|
||||
marketing_consent?: {
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
mergeFields: {
|
||||
FNAME: string;
|
||||
LNAME: string;
|
||||
ADDRESS: string | mailchimpAddress;
|
||||
COMPANY: string;
|
||||
PHONE: string;
|
||||
};
|
||||
};
|
||||
|
||||
type twentyAddress = {
|
||||
addressStreet1: string;
|
||||
addressStreet2: string;
|
||||
@@ -36,22 +62,27 @@ type twentyAddress = {
|
||||
addressCountry: string;
|
||||
};
|
||||
|
||||
type twentyCompany = {
|
||||
name: string;
|
||||
address: twentyAddress;
|
||||
};
|
||||
|
||||
type twentyPerson = {
|
||||
name: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
email: {
|
||||
emails: {
|
||||
primaryEmail: string;
|
||||
};
|
||||
phones: {
|
||||
primaryPhoneNumber: string;
|
||||
primaryPhoneCallingCode: string;
|
||||
};
|
||||
companyId: string;
|
||||
companyId: string | null;
|
||||
};
|
||||
|
||||
const fetchCompanyData = async (companyId: string): Promise<object> => {
|
||||
const fetchCompanyData = async (companyId: string): Promise<twentyCompany> => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -60,11 +91,13 @@ const fetchCompanyData = async (companyId: string): Promise<object> => {
|
||||
url: `${TWENTY_API_URL}/company/${companyId}`,
|
||||
};
|
||||
try {
|
||||
const temp = await axios.request(options);
|
||||
return {
|
||||
name: temp.data.name as string,
|
||||
address: temp.data.address as twentyAddress,
|
||||
};
|
||||
const response = await axios.request(options);
|
||||
return response.status === 200
|
||||
? ({
|
||||
name: response.data.name as string,
|
||||
address: response.data.address as twentyAddress,
|
||||
} as twentyCompany)
|
||||
: ({} as twentyCompany);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
@@ -94,7 +127,7 @@ const checkAddress = (address: twentyAddress): mailchimpAddress => {
|
||||
|
||||
const checkAudiencePermissions = async (
|
||||
audienceId: string,
|
||||
): Promise<string[]> => {
|
||||
): Promise<string[] | undefined> => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -109,7 +142,6 @@ const checkAudiencePermissions = async (
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,23 +152,23 @@ const prepareData = (
|
||||
phoneNumber: string,
|
||||
phoneCallingCode: string,
|
||||
companyName: string,
|
||||
address: mailchimpAddress | null,
|
||||
): object => {
|
||||
const data = {} as any;
|
||||
address: mailchimpAddress | string,
|
||||
): mailchimpRecord => {
|
||||
let data = {
|
||||
mergeFields: {},
|
||||
} as mailchimpRecord;
|
||||
data.mergeFields.FNAME = firstName;
|
||||
data.mergeFields.LNAME = lastName;
|
||||
if (IS_EMAIL_CONSTRAINT) {
|
||||
data['email_channel'] = {
|
||||
data.email_channel = {
|
||||
email: email,
|
||||
marketing_consent: {
|
||||
status: 'unknown',
|
||||
},
|
||||
};
|
||||
}
|
||||
data['mergeFields'] = {
|
||||
FNAME: firstName,
|
||||
LNAME: lastName,
|
||||
};
|
||||
if (IS_ADDRESS_CONSTRAINT) {
|
||||
data['mergeFields']['ADDRESS'] = address;
|
||||
data.mergeFields.ADDRESS = address;
|
||||
}
|
||||
if (IS_PHONE_CONSTRAINT) {
|
||||
const mergedPhoneNumber: string = phoneCallingCode.startsWith('+')
|
||||
@@ -156,110 +188,247 @@ const prepareData = (
|
||||
return data;
|
||||
};
|
||||
|
||||
const addTwentyPersonToMailchimp = async (
|
||||
convertedRecord: mailchimpRecord,
|
||||
): Promise<boolean | undefined> => {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
|
||||
},
|
||||
url: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts`,
|
||||
data: convertedRecord,
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkIfTwentyPersonExistsInMailchimp = async (
|
||||
email?: string,
|
||||
phoneNumber?: string,
|
||||
cursor?: string,
|
||||
) => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
|
||||
},
|
||||
url: cursor
|
||||
? `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts?count%3D1000%26cursor%3D${cursor}`
|
||||
: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts?count%3D1000`,
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
const doesPersonExist: mailchimpRecord | undefined =
|
||||
(response.data.contacts.find(
|
||||
(contact: any) => contact.email_channel.email === email,
|
||||
) as mailchimpRecord) ||
|
||||
(response.data.contacts.find(
|
||||
(contact: any) => contact.sms_channel.sms_phone === phoneNumber,
|
||||
) as mailchimpRecord);
|
||||
if (doesPersonExist !== undefined) {
|
||||
return doesPersonExist;
|
||||
}
|
||||
if (response.data.next_cursor === undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
await checkIfTwentyPersonExistsInMailchimp(
|
||||
email,
|
||||
phoneNumber,
|
||||
response.data.next_cursor,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const compareTwoRecords = (
|
||||
object1: mailchimpRecord,
|
||||
object2: mailchimpRecord,
|
||||
) => {
|
||||
return (
|
||||
object1.email_channel?.email === object2.email_channel?.email &&
|
||||
object1.sms_channel?.sms_phone === object2.sms_channel?.sms_phone &&
|
||||
object1.mergeFields.FNAME === object2.mergeFields.FNAME &&
|
||||
object1.mergeFields.LNAME === object2.mergeFields.LNAME &&
|
||||
object1.mergeFields.ADDRESS === object2.mergeFields.ADDRESS &&
|
||||
object1.mergeFields.COMPANY === object2.mergeFields.COMPANY
|
||||
);
|
||||
};
|
||||
|
||||
const updateTwentyPersonInMailchimp = async (
|
||||
mailchimpRecordId: string,
|
||||
twentyConvertedRecord: mailchimpRecord,
|
||||
): Promise<boolean | undefined> => {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts/${mailchimpRecordId}`,
|
||||
data: twentyConvertedRecord,
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const main = async (params: {
|
||||
properties: Record<string, any>;
|
||||
recordId: string;
|
||||
userId: string;
|
||||
}): Promise<object> => {
|
||||
}): Promise<object | undefined> => {
|
||||
if (!IS_EMAIL_CONSTRAINT && !IS_PHONE_CONSTRAINT) {
|
||||
console.log(
|
||||
console.warn(
|
||||
'Function exited as there are no constraints to email nor phone number',
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (
|
||||
MAILCHIMP_API_URL === '' ||
|
||||
MAILCHIMP_API_KEY === '' ||
|
||||
MAILCHIMP_AUDIENCE_ID === ''
|
||||
) {
|
||||
console.log('Missing Mailchimp required parameters');
|
||||
console.warn('Missing Mailchimp required parameters');
|
||||
return {};
|
||||
}
|
||||
|
||||
if (IS_COMPANY_CONSTRAINT && TWENTY_API_KEY === '') {
|
||||
console.log('Missing Twenty related parameters');
|
||||
console.warn('Missing Twenty related parameters');
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const { properties, recordId } = params;
|
||||
|
||||
console.log(properties);
|
||||
console.log(recordId);
|
||||
|
||||
const twentyRecord: twentyPerson = properties as twentyPerson;
|
||||
const email: boolean =
|
||||
IS_EMAIL_CONSTRAINT && twentyRecord.email.primaryEmail !== '';
|
||||
const phoneNumber: boolean =
|
||||
IS_PHONE_CONSTRAINT &&
|
||||
twentyRecord.phones.primaryPhoneNumber !== '' &&
|
||||
twentyRecord.phones.primaryPhoneCallingCode !== '';
|
||||
const company: any = IS_COMPANY_CONSTRAINT
|
||||
? await fetchCompanyData(properties.after.companyId)
|
||||
: null;
|
||||
const companyName: string = company['name'] !== '' ? company['name'] : null;
|
||||
const address: mailchimpAddress | null = IS_ADDRESS_CONSTRAINT
|
||||
? checkAddress(company['address'])
|
||||
: null;
|
||||
|
||||
const { properties } = params;
|
||||
const twentyRecord: twentyPerson = properties.after as twentyPerson;
|
||||
if (
|
||||
twentyRecord.name.firstName === '' ||
|
||||
twentyRecord.name.lastName === ''
|
||||
) {
|
||||
console.error('First or last name is empty');
|
||||
return {};
|
||||
throw new Error('First or last name is empty');
|
||||
}
|
||||
|
||||
const audiencePermissions: string[] = await checkAudiencePermissions(
|
||||
MAILCHIMP_AUDIENCE_ID,
|
||||
);
|
||||
const audiencePermissions: string[] | undefined =
|
||||
await checkAudiencePermissions(MAILCHIMP_AUDIENCE_ID);
|
||||
|
||||
if (
|
||||
IS_EMAIL_CONSTRAINT &&
|
||||
audiencePermissions.includes('Email') &&
|
||||
!email
|
||||
audiencePermissions?.includes('Email') &&
|
||||
twentyRecord.emails.primaryEmail === ''
|
||||
) {
|
||||
console.error('Email is empty');
|
||||
return {};
|
||||
throw new Error('Email is empty');
|
||||
}
|
||||
|
||||
if (
|
||||
IS_PHONE_CONSTRAINT &&
|
||||
audiencePermissions.includes('SMS') &&
|
||||
!phoneNumber
|
||||
audiencePermissions?.includes('SMS') &&
|
||||
(twentyRecord.phones.primaryPhoneNumber === '' ||
|
||||
twentyRecord.phones.primaryPhoneCallingCode === '')
|
||||
) {
|
||||
console.error('Phone number is empty');
|
||||
return {};
|
||||
throw new Error('Phone number is empty');
|
||||
}
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
|
||||
},
|
||||
url: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts`,
|
||||
data: prepareData(
|
||||
twentyRecord.name.firstName,
|
||||
twentyRecord.name.lastName,
|
||||
twentyRecord.email.primaryEmail,
|
||||
twentyRecord.phones.primaryPhoneNumber,
|
||||
twentyRecord.phones.primaryPhoneCallingCode,
|
||||
companyName,
|
||||
address,
|
||||
),
|
||||
};
|
||||
const temp = await axios.request(options);
|
||||
if (temp.status === 200) {
|
||||
console.log('Person has been successfully added');
|
||||
return {};
|
||||
} else {
|
||||
throw temp;
|
||||
let companyName: string = '';
|
||||
let address: mailchimpAddress | string = '';
|
||||
if (IS_COMPANY_CONSTRAINT) {
|
||||
if (twentyRecord.companyId === null) {
|
||||
throw new Error('Missing relation to company record');
|
||||
}
|
||||
const company: twentyCompany = await fetchCompanyData(
|
||||
twentyRecord.companyId,
|
||||
);
|
||||
companyName = company.name ?? ''; // either "" or name
|
||||
address = IS_ADDRESS_CONSTRAINT ? checkAddress(company.address) : '';
|
||||
}
|
||||
|
||||
const twentyPersonToMailchimpRecord: mailchimpRecord = prepareData(
|
||||
twentyRecord.name.firstName,
|
||||
twentyRecord.name.lastName,
|
||||
twentyRecord.emails.primaryEmail,
|
||||
twentyRecord.phones.primaryPhoneNumber,
|
||||
twentyRecord.phones.primaryPhoneCallingCode,
|
||||
companyName,
|
||||
address,
|
||||
);
|
||||
console.log(twentyPersonToMailchimpRecord);
|
||||
|
||||
const isTwentyPersonInMailchimp: mailchimpRecord | undefined =
|
||||
await checkIfTwentyPersonExistsInMailchimp(
|
||||
twentyRecord.emails.primaryEmail,
|
||||
twentyPersonToMailchimpRecord.sms_channel?.sms_phone,
|
||||
);
|
||||
if (isTwentyPersonInMailchimp !== undefined) {
|
||||
console.log(
|
||||
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} found in Mailchimp`,
|
||||
);
|
||||
if (UPDATE_PERSON) {
|
||||
if (
|
||||
!compareTwoRecords(
|
||||
isTwentyPersonInMailchimp,
|
||||
twentyPersonToMailchimpRecord,
|
||||
) &&
|
||||
isTwentyPersonInMailchimp.id
|
||||
) {
|
||||
const isTwentyPersonUpdatedInMailchimp: boolean | undefined =
|
||||
await updateTwentyPersonInMailchimp(
|
||||
isTwentyPersonInMailchimp.id,
|
||||
twentyPersonToMailchimpRecord,
|
||||
);
|
||||
if (!isTwentyPersonUpdatedInMailchimp) {
|
||||
throw new Error(
|
||||
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} update in Mailchimp failed`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} update in Mailchimp succeeded`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} wasn't updated in Mailchimp because they're the same`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} wasn't updated in Mailchimp because UPDATE_PERSON is set to false`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`${twentyRecord.name.firstName} ${twentyRecord.name.lastName} doesn't exist in Mailchimp, adding`,
|
||||
);
|
||||
const isTwentyPersonAddedToMailchimp: boolean | undefined =
|
||||
await addTwentyPersonToMailchimp(twentyPersonToMailchimpRecord);
|
||||
if (!isTwentyPersonAddedToMailchimp) {
|
||||
throw new Error(
|
||||
`Adding ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} person to Mailchimp failed`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} has been successfully added`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(error.message);
|
||||
console.error(error.response);
|
||||
return {};
|
||||
}
|
||||
console.error(error);
|
||||
@@ -276,5 +445,10 @@ export const config: ServerlessFunctionConfig = {
|
||||
type: 'databaseEvent',
|
||||
eventName: 'person.created',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '657ece26-4478-4408-a257-4e9e16cce279',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'person.updated',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,6 +5,15 @@ __metadata:
|
||||
version: 8
|
||||
cacheKey: 10c0
|
||||
|
||||
"@types/node@npm:^24.7.2":
|
||||
version: 24.10.0
|
||||
resolution: "@types/node@npm:24.10.0"
|
||||
dependencies:
|
||||
undici-types: "npm:~7.16.0"
|
||||
checksum: 10c0/f82ed7194e16f5590ef7afdc20c6d09068c76d50278b485ede8f0c5749683536e3064ffa8def8db76915196afb3724b854aa5723c64d6571b890b14492943b46
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-function@npm:1.0.0"
|
||||
@@ -26,14 +35,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^1.13.1":
|
||||
version: 1.13.1
|
||||
resolution: "axios@npm:1.13.1"
|
||||
"axios@npm:^1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "axios@npm:1.13.2"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.4"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10c0/de9c3c6de43d3ee1146d3afe78645f19450cac6a5d7235bef8b8e8eeb705c2e47e2d231dea99cecaec4dae1897c521118ca9413b9d474063c719c4d94c5b9adc
|
||||
checksum: 10c0/e8a42e37e5568ae9c7a28c348db0e8cf3e43d06fcbef73f0048669edfe4f71219664da7b6cc991b0c0f01c28a48f037c515263cb79be1f1ae8ff034cd813867b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -213,7 +222,9 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "mailchimp-synchronizer@workspace:."
|
||||
dependencies:
|
||||
axios: "npm:^1.13.1"
|
||||
"@types/node": "npm:^24.7.2"
|
||||
axios: "npm:^1.13.2"
|
||||
twenty-sdk: "npm:^0.0.4"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -246,3 +257,17 @@ __metadata:
|
||||
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"twenty-sdk@npm:^0.0.4":
|
||||
version: 0.0.4
|
||||
resolution: "twenty-sdk@npm:0.0.4"
|
||||
checksum: 10c0/550f1d85bf0701396c9dd2d4c6bc55ba1b067fce13636f8540eec60ab6a4257c6d7cd86cb3f62e0974bf99467bc31270d92b17b9681a1b7a6281b7ef97224080
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~7.16.0":
|
||||
version: 7.16.0
|
||||
resolution: "undici-types@npm:7.16.0"
|
||||
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
Binary file not shown.
@@ -5,7 +5,7 @@ Synchronizes customers from Stripe to Twenty
|
||||
## Requirements
|
||||
- twenty-cli `npm install -g twenty-cli`
|
||||
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
|
||||
-
|
||||
- Stripe secret API key - available in Stripe workbench
|
||||
|
||||
## Setup
|
||||
1. Synchronize app
|
||||
@@ -14,15 +14,24 @@ twenty auth login
|
||||
cd stripe-synchronizer
|
||||
twenty app sync
|
||||
```
|
||||
2. Go to Settings > Integrations > Stripe synchronizer > Settings and add values
|
||||
2. Go to Stripe > Workbench > Webhooks and add webhook:
|
||||
- events: customer.subscription.created and customer.subscription.updated
|
||||
- webhook endpoint
|
||||
- destination: `{TWENTY_URL}/s/webhook/stripe`, e.g. https://workspace.twenty.com/s/webhook/stripe
|
||||
3. Go to Twenty > Settings > Integrations > Stripe synchronizer > Settings and add values
|
||||
|
||||
## Flow
|
||||
1. Retrieve webhook
|
||||
2. Check if it's either subscription created or updated
|
||||
3. Read customer ID, sub status and quantity
|
||||
4. Read customer data
|
||||
1. Retrieve Stripe webhook
|
||||
2. Check if it's either subscription created or updated, if not, exit
|
||||
3. Read customer ID, sub status and quantity from webhook
|
||||
4. Read customer data from Stripe API, if business name is empty, exit
|
||||
5. Check if customer company exists in Twenty, if not, create it
|
||||
6. Check if related person exists in Twenty, if not, create it and link to company
|
||||
|
||||
## Notes
|
||||
- app synchronizes only new customers, those created before start of app won't be synchronized unless they're updated
|
||||
- customers will be added to Twenty People object only if their name and email are filled with data, otherwise app will throw an error
|
||||
|
||||
## Todo
|
||||
- add validation of signature key from Stripe to ensure that incoming request is valid
|
||||
- add validation of signature key from Stripe to ensure that incoming request is valid
|
||||
(possible once request headers are exposed to serverless functions)
|
||||
@@ -4,23 +4,21 @@ const config: ApplicationConfig = {
|
||||
universalIdentifier: '0ed2bcb8-64ab-4ca1-b875-eeabf41b5f95',
|
||||
displayName: 'Stripe synchronizer',
|
||||
description: 'Plugin synchronizing data from Stripe to Twenty',
|
||||
icon: "IconMoneyBag",
|
||||
applicationVariables: {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: 'b0d9569b-da3e-4dad-b7b1-36c96f0598b9',
|
||||
isSecret: true,
|
||||
value: '',
|
||||
description: 'Required to send requests to Twenty',
|
||||
},
|
||||
TWENTY_API_URL: {
|
||||
universalIdentifier: 'fa50e016-e045-497a-9cdf-0949e7ef9f7a',
|
||||
isSecret: false,
|
||||
value: '',
|
||||
description: 'Optional, defaults to cloud API URL',
|
||||
},
|
||||
STRIPE_API_KEY: {
|
||||
universalIdentifier: '807d67d6-f720-49c4-a93e-ef16cf4fe919',
|
||||
isSecret: true,
|
||||
value: '',
|
||||
description: 'Required to send request to Stripe',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"twenty-sdk": "^0.0.3"
|
||||
"axios": "^1.13.2",
|
||||
"twenty-sdk": "^0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
|
||||
+250
-104
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
|
||||
import { type stripeCustomer, type stripeEvent, type stripeStatus, type twentyObject } from './types';
|
||||
|
||||
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
|
||||
const TWENTY_API_URL: string =
|
||||
@@ -9,43 +10,9 @@ const TWENTY_API_URL: string =
|
||||
const STRIPE_API_KEY: string = process.env.STRIPE_API_KEY ?? '';
|
||||
const STRIPE_API_URL: string = 'https://api.stripe.com/v1/customers';
|
||||
|
||||
enum stripeStatus {
|
||||
Incomplete = 'INCOMPLETE',
|
||||
IncompleteExpired = 'INCOMPLETE_EXPIRED',
|
||||
Trialing = 'TRIALING',
|
||||
Active = 'ACTIVE',
|
||||
PastDue = 'PAST_DUE',
|
||||
Canceled = 'CANCELED',
|
||||
Unpaid = 'UNPAID',
|
||||
Paused = 'PAUSED',
|
||||
}
|
||||
|
||||
type stripeData = {
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
type stripeItems = {
|
||||
data: stripeData[];
|
||||
};
|
||||
|
||||
type stripeResponse = {
|
||||
customer: string;
|
||||
items: stripeItems;
|
||||
status: stripeStatus;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type stripeCustomer = {
|
||||
businessName: string;
|
||||
};
|
||||
|
||||
type twentyObject = {
|
||||
id: string;
|
||||
nameSingular: string;
|
||||
fields: Record<string, any>[];
|
||||
};
|
||||
|
||||
const getCompaniesObject = async (): Promise<twentyObject | undefined> => {
|
||||
const getTwentyObjectData = async (
|
||||
objectSingularName: string,
|
||||
): Promise<twentyObject | undefined> => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -57,7 +24,7 @@ const getCompaniesObject = async (): Promise<twentyObject | undefined> => {
|
||||
const response = await axios.request(options);
|
||||
if (response.status === 200) {
|
||||
const companyObject = response.data.data.objects.find(
|
||||
(object: twentyObject) => object.nameSingular === 'company',
|
||||
(object: twentyObject) => object.nameSingular === objectSingularName,
|
||||
);
|
||||
return (companyObject as twentyObject) ?? ({} as twentyObject);
|
||||
}
|
||||
@@ -76,59 +43,61 @@ const createFields = async (objectId: string, fieldName: string) => {
|
||||
objectMetadataId: objectId,
|
||||
name: 'seats',
|
||||
label: 'Seats',
|
||||
icon: 'IconMan',
|
||||
}
|
||||
: {
|
||||
type: 'SELECT',
|
||||
objectMetadataId: objectId,
|
||||
name: 'subStatus',
|
||||
label: 'Sub Status',
|
||||
icon: 'IconStatusChange',
|
||||
options: [
|
||||
{
|
||||
color: 'iris',
|
||||
label: 'Incomplete',
|
||||
value: stripeStatus.Incomplete,
|
||||
value: 'INCOMPLETE',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
color: 'sky',
|
||||
label: 'Incomplete (expired)',
|
||||
value: stripeStatus.IncompleteExpired,
|
||||
value: 'INCOMPLETE_EXPIRED',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
color: 'amber',
|
||||
label: 'Trialing',
|
||||
value: stripeStatus.Trialing,
|
||||
value: 'TRIALING',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
color: 'green',
|
||||
label: 'Active',
|
||||
value: stripeStatus.Active,
|
||||
value: 'ACTIVE',
|
||||
position: 4,
|
||||
},
|
||||
{
|
||||
color: 'orange',
|
||||
label: 'Past due',
|
||||
value: stripeStatus.PastDue,
|
||||
value: 'PAST_DUE',
|
||||
position: 5,
|
||||
},
|
||||
{
|
||||
color: 'brown',
|
||||
label: 'Canceled',
|
||||
value: stripeStatus.Canceled,
|
||||
value: 'CANCELED',
|
||||
position: 6,
|
||||
},
|
||||
{
|
||||
color: 'red',
|
||||
label: 'Unpaid',
|
||||
value: stripeStatus.Unpaid,
|
||||
value: 'UNPAID',
|
||||
position: 7,
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
label: 'Paused',
|
||||
value: stripeStatus.Paused,
|
||||
value: 'PAUSED',
|
||||
position: 8,
|
||||
},
|
||||
],
|
||||
@@ -167,7 +136,11 @@ const getStripeCustomerData = async (
|
||||
try {
|
||||
const response = await axios(options);
|
||||
return response.status === 200
|
||||
? (response.data as stripeCustomer)
|
||||
? ({
|
||||
name: response.data.name,
|
||||
businessName: response.data.business_name,
|
||||
email: response.data.email,
|
||||
} as stripeCustomer)
|
||||
: ({} as stripeCustomer);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -176,10 +149,9 @@ const getStripeCustomerData = async (
|
||||
}
|
||||
};
|
||||
|
||||
const checkIfCompanyExistsInTwenty = async (name: string | undefined) => {
|
||||
if (!name) {
|
||||
return {};
|
||||
}
|
||||
const checkIfCompanyExistsInTwenty = async (
|
||||
name: string | undefined,
|
||||
): Promise<string | undefined> => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -189,9 +161,10 @@ const checkIfCompanyExistsInTwenty = async (name: string | undefined) => {
|
||||
};
|
||||
try {
|
||||
const response = await axios(options);
|
||||
return response.status === 200 && response.data.data.companies.length > 0
|
||||
? response.data.data.companies[0]
|
||||
: {};
|
||||
return response.status === 200 &&
|
||||
response.data.data.companies[0].id !== undefined
|
||||
? (response.data.data.companies[0].id as string)
|
||||
: '';
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
@@ -200,8 +173,8 @@ const checkIfCompanyExistsInTwenty = async (name: string | undefined) => {
|
||||
};
|
||||
|
||||
const updateTwentyCompany = async (
|
||||
companyId: string,
|
||||
seats: number,
|
||||
companyId: string | undefined,
|
||||
seats: number | null,
|
||||
subStatus: stripeStatus,
|
||||
): Promise<boolean | undefined> => {
|
||||
const options = {
|
||||
@@ -226,11 +199,11 @@ const updateTwentyCompany = async (
|
||||
}
|
||||
};
|
||||
|
||||
const createTwentyCustomer = async (
|
||||
const createTwentyCompany = async (
|
||||
customerName: string | undefined,
|
||||
seats: number,
|
||||
seats: number | null,
|
||||
subStatus: string,
|
||||
) => {
|
||||
): Promise<string | undefined> => {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -246,7 +219,7 @@ const createTwentyCustomer = async (
|
||||
};
|
||||
try {
|
||||
const response = await axios(options);
|
||||
return response.status === 201 ? response.data : {};
|
||||
return response.status === 201 ? (response.data.data.id as string) : '';
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
@@ -254,85 +227,258 @@ const createTwentyCustomer = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const main = async (params: {
|
||||
properties: unknown;
|
||||
}): Promise<object | undefined> => {
|
||||
const checkIfStripePersonExistsInTwenty = async (email: string | null) => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: `${TWENTY_API_URL}/people?filter=emails.primaryEmail%5Beq%5D%3A%22${email}%22`, // mail is unique by default so there can be only 1 person with given mail
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
return response.status === 200 &&
|
||||
response.data.data.people[0].id !== undefined
|
||||
? (response.data.data.people[0].id as string)
|
||||
: '';
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addTwentyPerson = async (
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
email: string,
|
||||
companyId: string,
|
||||
seats: number,
|
||||
subStatus: stripeStatus,
|
||||
): Promise<boolean | undefined> => {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: `${TWENTY_API_URL}/people`,
|
||||
data: {
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
emails: { primaryEmail: email },
|
||||
companyId: companyId,
|
||||
seats: seats,
|
||||
subStatus: subStatus,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
return response.status === 201;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateTwentyPerson = async (
|
||||
id: string,
|
||||
seats: number,
|
||||
subStatus: stripeStatus,
|
||||
): Promise<boolean | undefined> => {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: `${TWENTY_API_URL}/people/${id}`,
|
||||
data: {
|
||||
seats: seats,
|
||||
subStatus: subStatus,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const main = async (
|
||||
params: Record<string, any>,
|
||||
): Promise<object | undefined> => {
|
||||
if (TWENTY_API_KEY === '' || STRIPE_API_KEY === '') {
|
||||
console.warn('Missing variables');
|
||||
return {};
|
||||
throw new Error('Missing variables');
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: add validation of signature key from Stripe
|
||||
const { properties } = params;
|
||||
const stripe = properties as stripeResponse;
|
||||
const allowed_types = [
|
||||
// TODO: add validation of signature key from Stripe (not possible at the moment as headers aren't accessible in serverless functions)
|
||||
const stripe = params as stripeEvent;
|
||||
const allowed_types: string[] = [
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated',
|
||||
];
|
||||
if (!allowed_types.includes(stripe.type)) {
|
||||
console.error('Wrong webhook');
|
||||
throw new Error('Wrong type of webhook');
|
||||
}
|
||||
|
||||
const stripeCustomer: stripeCustomer | undefined =
|
||||
await getStripeCustomerData(stripe.data.object.customer);
|
||||
if (
|
||||
stripeCustomer?.businessName === undefined ||
|
||||
stripeCustomer?.businessName === ''
|
||||
) {
|
||||
console.warn('Set customer business name in Stripe');
|
||||
return {};
|
||||
}
|
||||
|
||||
const companyObject = await getCompaniesObject();
|
||||
const companyObject = await getTwentyObjectData('company');
|
||||
if (
|
||||
companyObject?.fields.find((field) => field.name === 'seats') ===
|
||||
undefined
|
||||
) {
|
||||
const t: boolean | undefined = companyObject?.id
|
||||
const seatsFieldCreated: boolean | undefined = companyObject?.id
|
||||
? await createFields(companyObject?.id, 'seats')
|
||||
: false;
|
||||
if (t === false) {
|
||||
console.error('Seats field creation failed');
|
||||
return {};
|
||||
if (!seatsFieldCreated) {
|
||||
throw new Error('Seats field creation in Company object failed');
|
||||
} else {
|
||||
console.info('Seats field creation in Company object succeeded');
|
||||
}
|
||||
}
|
||||
if (
|
||||
companyObject?.fields.find((field) => field.name === 'subStatus') ===
|
||||
undefined
|
||||
) {
|
||||
const t: boolean | undefined = companyObject?.id
|
||||
const subStatusFieldCreated: boolean | undefined = companyObject?.id
|
||||
? await createFields(companyObject?.id, 'subStatus')
|
||||
: false;
|
||||
if (t === false) {
|
||||
console.error('Sub status field creation failed');
|
||||
return {};
|
||||
if (!subStatusFieldCreated) {
|
||||
throw new Error('Sub status field creation in Company object failed');
|
||||
} else {
|
||||
console.info('Sub status field creation in Company object succeeded');
|
||||
}
|
||||
}
|
||||
|
||||
const stripeCustomer = await getStripeCustomerData(stripe.customer);
|
||||
if (stripeCustomer?.businessName) {
|
||||
console.warn('Set customer business name in Stripe');
|
||||
return {};
|
||||
const personObject = await getTwentyObjectData('person');
|
||||
if (
|
||||
personObject?.fields.find((field) => field.name === 'seats') === undefined
|
||||
) {
|
||||
const seatsFieldCreated: boolean | undefined = personObject?.id
|
||||
? await createFields(personObject?.id, 'seats')
|
||||
: false;
|
||||
if (!seatsFieldCreated) {
|
||||
throw new Error('Seats field creation in People object failed');
|
||||
} else {
|
||||
console.info('Seats field creation in People object succeeded');
|
||||
}
|
||||
}
|
||||
const twentyCustomer = await checkIfCompanyExistsInTwenty(
|
||||
stripeCustomer?.businessName,
|
||||
);
|
||||
if (Object.keys(twentyCustomer).length === 0) {
|
||||
const a = await createTwentyCustomer(
|
||||
stripeCustomer?.businessName,
|
||||
stripe.items.data[0].quantity,
|
||||
stripe.status.toUpperCase(),
|
||||
);
|
||||
if (Object.keys(a).length === 0) {
|
||||
console.error('Creation of Stripe customer in Twenty failed');
|
||||
return {};
|
||||
if (
|
||||
personObject?.fields.find((field) => field.name === 'subStatus') ===
|
||||
undefined
|
||||
) {
|
||||
const subStatusFieldCreated: boolean | undefined = personObject?.id
|
||||
? await createFields(personObject?.id, 'subStatus')
|
||||
: false;
|
||||
if (!subStatusFieldCreated) {
|
||||
throw new Error('Sub status field creation in People object failed');
|
||||
} else {
|
||||
console.info('Sub status field creation in People object succeeded');
|
||||
}
|
||||
}
|
||||
|
||||
const twentyCompanyId: string | undefined =
|
||||
await checkIfCompanyExistsInTwenty(stripeCustomer?.businessName);
|
||||
const seats: number =
|
||||
stripe.data.object.quantity ??
|
||||
stripe.data.object.items.data.reduce(
|
||||
(acc, item) => acc + item.quantity,
|
||||
0,
|
||||
); // we don't know if subscription has only 1 item (product) or more
|
||||
let updatedTwentyCompanyId: string | undefined;
|
||||
if (twentyCompanyId === '') {
|
||||
const twentyCompanyCreated: string | undefined =
|
||||
await createTwentyCompany(
|
||||
stripeCustomer?.businessName,
|
||||
seats,
|
||||
stripe.data.object.status.toUpperCase(),
|
||||
);
|
||||
if (twentyCompanyCreated === '') {
|
||||
throw new Error('Creation of Stripe customer in Twenty failed');
|
||||
} else {
|
||||
console.log('Creation of Stripe customer in Twenty succeeded');
|
||||
updatedTwentyCompanyId = twentyCompanyCreated;
|
||||
}
|
||||
} else {
|
||||
const a = await updateTwentyCompany(
|
||||
twentyCustomer.id,
|
||||
stripe.items.data[0].quantity,
|
||||
stripe.status.toUpperCase() as stripeStatus,
|
||||
);
|
||||
if (!a) {
|
||||
console.error('Update of Stripe customer in Twenty failed');
|
||||
return {};
|
||||
const twentyCompanyUpdated: boolean | undefined =
|
||||
await updateTwentyCompany(
|
||||
twentyCompanyId,
|
||||
seats,
|
||||
stripe.data.object.status.toUpperCase() as stripeStatus,
|
||||
);
|
||||
if (!twentyCompanyUpdated) {
|
||||
throw new Error('Update of Stripe customer in Twenty failed');
|
||||
} else {
|
||||
console.log('Update of Stripe customer in Twenty succeeded');
|
||||
updatedTwentyCompanyId = twentyCompanyId;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedTwentyCompanyId === undefined || updatedTwentyCompanyId === '') {
|
||||
throw new Error('TwentyCompanyId not found');
|
||||
} else {
|
||||
const stripeCustomerInTwenty: string | undefined =
|
||||
await checkIfStripePersonExistsInTwenty(stripeCustomer.email);
|
||||
if (stripeCustomerInTwenty === '') {
|
||||
if (!stripeCustomer.name) {
|
||||
throw new Error('Missing Stripe customer first or last name');
|
||||
}
|
||||
if (!stripeCustomer.email) {
|
||||
throw new Error('Missing Stripe customer email');
|
||||
}
|
||||
const firstName: string = stripeCustomer.name?.split(' ')[0];
|
||||
const lastName: string = stripeCustomer.name?.split(' ')[1];
|
||||
const addedStripePersonToTwenty: boolean | undefined =
|
||||
await addTwentyPerson(
|
||||
firstName,
|
||||
lastName,
|
||||
stripeCustomer.email,
|
||||
updatedTwentyCompanyId,
|
||||
seats,
|
||||
stripe.data.object.status.toUpperCase() as stripeStatus,
|
||||
);
|
||||
if (!addedStripePersonToTwenty) {
|
||||
throw new Error('Adding Stripe person to Twenty failed');
|
||||
} else {
|
||||
console.log('Stripe person was added to Twenty');
|
||||
}
|
||||
} else if (stripeCustomerInTwenty !== undefined) {
|
||||
const updatedStripePersonInTwenty: boolean | undefined =
|
||||
await updateTwentyPerson(
|
||||
stripeCustomerInTwenty,
|
||||
seats,
|
||||
stripe.data.object.status.toUpperCase() as stripeStatus,
|
||||
);
|
||||
if (!updatedStripePersonInTwenty) {
|
||||
throw new Error('Update of Stripe person in Twenty failed');
|
||||
} else {
|
||||
console.log('Update of Stripe person in Twenty succeeded');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Twenty not found');
|
||||
}
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(error.message);
|
||||
return {};
|
||||
}
|
||||
console.error(error);
|
||||
return {};
|
||||
@@ -346,7 +492,7 @@ export const config: ServerlessFunctionConfig = {
|
||||
{
|
||||
universalIdentifier: '55f58e19-d832-43c4-9f8b-3f29fc05c162',
|
||||
type: 'route',
|
||||
path: '/stripe',
|
||||
path: '/webhook/stripe',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
export type stripeStatus = 'INCOMPLETE' | 'INCOMPLETE_EXPIRED' | 'TRIALING' | 'ACTIVE' | 'PAST_DUE' | 'CANCELED' | 'UNPAID' | 'PAUSED';
|
||||
|
||||
type stripeItem = {
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
type stripeItemsData = {
|
||||
data: stripeItem[];
|
||||
};
|
||||
|
||||
type stripeEventObject = {
|
||||
customer: string;
|
||||
items: stripeItemsData;
|
||||
status: stripeStatus;
|
||||
quantity: number | null;
|
||||
};
|
||||
|
||||
type stripeEventData = {
|
||||
object: stripeEventObject;
|
||||
};
|
||||
|
||||
export type stripeEvent = {
|
||||
data: stripeEventData;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type stripeCustomer = {
|
||||
businessName?: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
|
||||
export type twentyObject = {
|
||||
id: string;
|
||||
nameSingular: string;
|
||||
fields: Record<string, any>[];
|
||||
};
|
||||
@@ -5,6 +5,15 @@ __metadata:
|
||||
version: 8
|
||||
cacheKey: 10c0
|
||||
|
||||
"@types/node@npm:^24.7.2":
|
||||
version: 24.10.0
|
||||
resolution: "@types/node@npm:24.10.0"
|
||||
dependencies:
|
||||
undici-types: "npm:~7.16.0"
|
||||
checksum: 10c0/f82ed7194e16f5590ef7afdc20c6d09068c76d50278b485ede8f0c5749683536e3064ffa8def8db76915196afb3724b854aa5723c64d6571b890b14492943b46
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-function@npm:1.0.0"
|
||||
@@ -26,14 +35,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^1.13.1":
|
||||
version: 1.13.1
|
||||
resolution: "axios@npm:1.13.1"
|
||||
"axios@npm:^1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "axios@npm:1.13.2"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.4"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10c0/de9c3c6de43d3ee1146d3afe78645f19450cac6a5d7235bef8b8e8eeb705c2e47e2d231dea99cecaec4dae1897c521118ca9413b9d474063c719c4d94c5b9adc
|
||||
checksum: 10c0/e8a42e37e5568ae9c7a28c348db0e8cf3e43d06fcbef73f0048669edfe4f71219664da7b6cc991b0c0f01c28a48f037c515263cb79be1f1ae8ff034cd813867b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -243,6 +252,22 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "stripe-synchronizer@workspace:."
|
||||
dependencies:
|
||||
axios: "npm:^1.13.1"
|
||||
"@types/node": "npm:^24.7.2"
|
||||
axios: "npm:^1.13.2"
|
||||
twenty-sdk: "npm:^0.0.4"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"twenty-sdk@npm:^0.0.4":
|
||||
version: 0.0.4
|
||||
resolution: "twenty-sdk@npm:0.0.4"
|
||||
checksum: 10c0/550f1d85bf0701396c9dd2d4c6bc55ba1b067fce13636f8540eec60ab6a4257c6d7cd86cb3f62e0974bf99467bc31270d92b17b9681a1b7a6281b7ef97224080
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~7.16.0":
|
||||
version: 7.16.0
|
||||
resolution: "undici-types@npm:7.16.0"
|
||||
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2",
|
||||
"twenty-sdk": "^0.0.3"
|
||||
"twenty-sdk": "^0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
|
||||
@@ -224,7 +224,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@types/node": "npm:^24.7.2"
|
||||
axios: "npm:^1.12.2"
|
||||
twenty-sdk: "npm:0.0.3-alpha"
|
||||
twenty-sdk: "npm:^0.0.4"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -258,10 +258,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"twenty-sdk@npm:0.0.3-alpha":
|
||||
version: 0.0.3-alpha
|
||||
resolution: "twenty-sdk@npm:0.0.3-alpha"
|
||||
checksum: 10c0/e8028f47767e3fa6318100f26542e0477b68e36d363df5f3a6c20d8442951fecba60089d07e97224b0b3533a729bbd3526cca08728079577cfa204dbffaecb03
|
||||
"twenty-sdk@npm:^0.0.4":
|
||||
version: 0.0.4
|
||||
resolution: "twenty-sdk@npm:0.0.4"
|
||||
checksum: 10c0/550f1d85bf0701396c9dd2d4c6bc55ba1b067fce13636f8540eec60ab6a4257c6d7cd86cb3f62e0974bf99467bc31270d92b17b9681a1b7a6281b7ef97224080
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user