diff --git a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/.yarn/install-state.gz b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/.yarn/install-state.gz index 4e0bb6876d..fa2d957d93 100644 Binary files a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/.yarn/install-state.gz and b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/.yarn/install-state.gz differ diff --git a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/README.md b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/README.md index d954bf3281..7153e2b486 100644 --- a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/README.md +++ b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/README.md @@ -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 \ No newline at end of file diff --git a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/application.config.ts b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/application.config.ts index 4169ea83d8..72427e0e4e 100644 --- a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/application.config.ts +++ b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/application.config.ts @@ -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', }, }, diff --git a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/package.json b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/package.json index 792ccd5e6d..d61e24601f 100644 --- a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/package.json +++ b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/package.json @@ -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" diff --git a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/serverlessFunctions/test/src/index.ts b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/serverlessFunctions/test/src/index.ts index c26e60d595..93178f7b7e 100644 --- a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/serverlessFunctions/test/src/index.ts +++ b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/serverlessFunctions/test/src/index.ts @@ -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; 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 {}; } }; diff --git a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/yarn.lock b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/yarn.lock index 816b723340..b9e0067362 100644 --- a/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/yarn.lock +++ b/packages/twenty-apps/hacktoberfest-2025/last-email-interaction/yarn.lock @@ -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 diff --git a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/.yarn/install-state.gz b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/.yarn/install-state.gz index 880526dee4..54e1243c1b 100644 Binary files a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/.yarn/install-state.gz and b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/.yarn/install-state.gz differ diff --git a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/README.md b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/README.md index 343a141606..023e0c4d11 100644 --- a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/README.md +++ b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/README.md @@ -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 \ No newline at end of file +- 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 \ No newline at end of file diff --git a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/application.config.ts b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/application.config.ts index b0dfedae96..3de3b32dab 100644 --- a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/application.config.ts +++ b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/application.config.ts @@ -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', }, }, }; diff --git a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/package.json b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/package.json index 0081e496bd..654f8a3e3c 100644 --- a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/package.json +++ b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/package.json @@ -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" diff --git a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/serverlessFunctions/mailchimp/src/index.ts b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/serverlessFunctions/mailchimp/src/index.ts index db191ba9f2..4ce00dac41 100644 --- a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/serverlessFunctions/mailchimp/src/index.ts +++ b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/serverlessFunctions/mailchimp/src/index.ts @@ -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 => { +const fetchCompanyData = async (companyId: string): Promise => { const options = { method: 'GET', headers: { @@ -60,11 +91,13 @@ const fetchCompanyData = async (companyId: string): Promise => { 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 => { +): Promise => { 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 => { + 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 => { + 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; recordId: string; userId: string; -}): Promise => { +}): Promise => { 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', + }, ], }; diff --git a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/yarn.lock b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/yarn.lock index 8b626e0b40..f394702ffd 100644 --- a/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/yarn.lock +++ b/packages/twenty-apps/hacktoberfest-2025/mailchimp-synchronizer/yarn.lock @@ -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 diff --git a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/.yarn/install-state.gz b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/.yarn/install-state.gz index c5a6ac9676..d1c64dfe95 100644 Binary files a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/.yarn/install-state.gz and b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/.yarn/install-state.gz differ diff --git a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/README.md b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/README.md index 6559219f0f..cb55bba6f7 100644 --- a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/README.md +++ b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/README.md @@ -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 \ No newline at end of file +- add validation of signature key from Stripe to ensure that incoming request is valid + (possible once request headers are exposed to serverless functions) \ No newline at end of file diff --git a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/application.config.ts b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/application.config.ts index e4e3e5f4f7..fb496a0003 100644 --- a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/application.config.ts +++ b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/application.config.ts @@ -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', }, }, diff --git a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/package.json b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/package.json index b67ad4d2db..daaf361ee7 100644 --- a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/package.json +++ b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/package.json @@ -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" diff --git a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/serverlessFunctions/stripe/src/index.ts b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/serverlessFunctions/stripe/src/index.ts index 8fd54b9548..a007e545b7 100644 --- a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/serverlessFunctions/stripe/src/index.ts +++ b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/serverlessFunctions/stripe/src/index.ts @@ -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[]; -}; - -const getCompaniesObject = async (): Promise => { +const getTwentyObjectData = async ( + objectSingularName: string, +): Promise => { const options = { method: 'GET', headers: { @@ -57,7 +24,7 @@ const getCompaniesObject = async (): Promise => { 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 => { 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 => { 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 => { 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 => { +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 => { + 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 => { + 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, +): Promise => { 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, }, diff --git a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/serverlessFunctions/stripe/src/types.ts b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/serverlessFunctions/stripe/src/types.ts new file mode 100644 index 0000000000..ab7f29601b --- /dev/null +++ b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/serverlessFunctions/stripe/src/types.ts @@ -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[]; +}; \ No newline at end of file diff --git a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/yarn.lock b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/yarn.lock index d915cde279..ab7ce2976d 100644 --- a/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/yarn.lock +++ b/packages/twenty-apps/hacktoberfest-2025/stripe-synchronizer/yarn.lock @@ -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 diff --git a/packages/twenty-apps/hello-world/package.json b/packages/twenty-apps/hello-world/package.json index b9d6159f46..e82d368c11 100644 --- a/packages/twenty-apps/hello-world/package.json +++ b/packages/twenty-apps/hello-world/package.json @@ -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" diff --git a/packages/twenty-apps/hello-world/yarn.lock b/packages/twenty-apps/hello-world/yarn.lock index 755c8ef66c..429a933e68 100644 --- a/packages/twenty-apps/hello-world/yarn.lock +++ b/packages/twenty-apps/hello-world/yarn.lock @@ -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