Fix hacktoberfest applications (#15613)

As title, make them syncable with twenty-cli:0.2.0
<img width="1025" height="693" alt="image"
src="https://github.com/user-attachments/assets/a18f8ba3-b6fc-40e9-84dd-446ff8deeb04"
/>
This commit is contained in:
martmull
2025-11-04 18:40:06 +01:00
committed by GitHub
parent b5064e88f6
commit 86a6e04d78
86 changed files with 2329 additions and 981 deletions
@@ -0,0 +1,45 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: 'b53627f5-ca60-478c-bc43-c7ab4904e34a',
displayName: 'Activity Summary',
description:
'A TypeScript-based reporting bot that summarizes activity from your Twenty CRM workspace and sends daily/periodic reports to Slack, Discord, and WhatsApp. Meet Kylian Mbaguette, your friendly CRM activity reporter!',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '304b7d5d-e2bb-4444-9b04-6b3ae8b73730',
description: 'Twenty API Key',
isSecret: true,
},
DAYS_AGO: {
universalIdentifier: '040a3097-9cee-4f74-b957-c2f9bf636c3f',
description:
'How far back into the past we want to summarise defaults to the past 7 days',
value: '7',
isSecret: false,
},
SLACK_HOOK_URL: {
universalIdentifier: 'fd16e370-934c-4267-83b4-7d88259bf7e1',
description: 'Slack hook URL for sending message to channel',
isSecret: true,
},
DISCORD_WEBHOOK_URL: {
universalIdentifier: 'f3741075-d525-4988-ba42-55d519c6fd76',
description:
'Discord webhook URL for sending message to channel of a server',
isSecret: true,
},
FB_GRAPH_TOKEN: {
universalIdentifier: 'fb907f49-74ac-4aa5-ba45-cfc9250ecc44',
description: 'For Facebook auth',
isSecret: true,
},
WHATSAPP_RECIPIENT_PHONE_NUMBER: {
universalIdentifier: 'c856ee5d-44bf-42f4-9a39-2553a94af518',
description: 'Phone number for receiving WhatsApp message',
isSecret: true,
},
},
};
export default config;
@@ -1,4 +1,5 @@
{
"name": "activity-summary",
"version": "0.0.1",
"license": "MIT",
"engines": {
@@ -12,36 +13,5 @@
},
"devDependencies": {
"@types/node": "^24.7.2"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "b53627f5-ca60-478c-bc43-c7ab4904e34a",
"name": "Activity Summary",
"description": "A TypeScript-based reporting bot that summarizes activity from your Twenty CRM workspace and sends daily/periodic reports to Slack, Discord, and WhatsApp. Meet Kylian Mbaguette, your friendly CRM activity reporter!",
"env": {
"TWENTY_API_KEY": {
"description": "Twenty API Key",
"isSecret": true
},
"DAYS_AGO": {
"description": "How far back into the past we want to summarise defaults to the past 7 days",
"value": "7",
"isSecret": false
},
"SLACK_HOOK_URL": {
"description": "Slack hook URL for sending message to channel",
"isSecret": true
},
"DISCORD_WEBHOOK_URL": {
"description": "Discord webhook URL for sending message to channel of a server",
"isSecret": true
},
"FB_GRAPH_TOKEN": {
"description": "For Facebook auth",
"isSecret": true
},
"WHATSAPP_RECIPIENT_PHONE_NUMBER": {
"description": "Phone number for receiving WhatsApp message",
"isSecret": true
}
}
}
@@ -1,12 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "c5b0e3f7-cbbd-4bd6-b01c-150d52cf2ce9",
"name": "summarise-and-send",
"triggers": [
{
"universalIdentifier": "36e1c4c7-8664-4d6d-a88f-ac56f1bd0651",
"type": "cron",
"schedule": "0 9 * * *"
}
]
}
@@ -1,15 +1,16 @@
import { summariseOpportunityCreation } from "./opportunity-creation-summariser";
import { summarisePeopleCreation } from "./people-creation-summariser";
import { sendToDiscord, sendToSlack, sendToWhatsApp } from "./senders";
import { summariseTaskCreation } from "./task-creation-summariser";
import { summariseOpportunityCreation } from './opportunity-creation-summariser';
import { summarisePeopleCreation } from './people-creation-summariser';
import { sendToDiscord, sendToSlack, sendToWhatsApp } from './senders';
import { summariseTaskCreation } from './task-creation-summariser';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (): Promise<object> => {
let date: string | Date = new Date()
date.setDate(new Date().getDate() - Number(process.env.DAYS_AGO))
date = date.toISOString().substring(0, 10)
const peopleCreationSummary = await summarisePeopleCreation(date)
const opportunityCreationSummary = await summariseOpportunityCreation(date)
const taskCreationSummary = await summariseTaskCreation(date)
let date: string | Date = new Date();
date.setDate(new Date().getDate() - Number(process.env.DAYS_AGO));
date = date.toISOString().substring(0, 10);
const peopleCreationSummary = await summarisePeopleCreation(date);
const opportunityCreationSummary = await summariseOpportunityCreation(date);
const taskCreationSummary = await summariseTaskCreation(date);
let body = {
daysAgo: Number(process.env.DAYS_AGO),
@@ -19,19 +20,19 @@ export const main = async (): Promise<object> => {
discord: {},
whatsapp: {},
slack: {},
}
};
if (process.env.SLACK_HOOK_URL) {
const slackBody = await sendToSlack({
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
})
});
body = {
...body,
slack: slackBody,
}
};
}
if (process.env.DISCORD_WEBHOOK_URL) {
@@ -39,26 +40,41 @@ export const main = async (): Promise<object> => {
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
})
});
body = {
...body,
discord: discordBody,
}
};
}
if (process.env.FB_GRAPH_TOKEN && process.env.WHATSAPP_RECIPIENT_PHONE_NUMBER) {
if (
process.env.FB_GRAPH_TOKEN &&
process.env.WHATSAPP_RECIPIENT_PHONE_NUMBER
) {
const whatsappBody = await sendToWhatsApp({
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
})
});
body = {
...body,
whatsapp: whatsappBody,
}
};
}
return body
}
return body;
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'c5b0e3f7-cbbd-4bd6-b01c-150d52cf2ce9',
name: 'summarise-and-send',
triggers: [
{
universalIdentifier: '36e1c4c7-8664-4d6d-a88f-ac56f1bd0651',
type: 'cron',
pattern: '0 9 * * *',
},
],
};
@@ -1,93 +1,97 @@
export const sendToSlack = async (params: {
peopleCreationSummary: string
opportunityCreationSummary: string
taskCreationSummary: string
}) => {
const { peopleCreationSummary, opportunityCreationSummary, taskCreationSummary } = params
const slackMessage = {
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days`,
"emoji": true
}
},
{
"type": "header",
"text": {
"type": "plain_text",
"text": "🧑‍💻 People & Companies",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "plain_text",
"text": peopleCreationSummary,
"emoji": true
}
},
{
"type": "header",
"text": {
"type": "plain_text",
"text": "🎯 Opportunities",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "plain_text",
"text": opportunityCreationSummary,
"emoji": true
}
},
{
"type": "header",
"text": {
"type": "plain_text",
"text": "📋 Tasks",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "plain_text",
"text": taskCreationSummary,
"emoji": true
}
},
]
}
const response = await fetch(process.env.SLACK_HOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(slackMessage),
})
return {
formattedMesage: slackMessage,
webhookStatus: response.status,
}
}
export const sendToDiscord = async (params: {
peopleCreationSummary: string
opportunityCreationSummary: string
taskCreationSummary: string
peopleCreationSummary: string;
opportunityCreationSummary: string;
taskCreationSummary: string;
}) => {
const {
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
} = params
} = params;
const slackMessage = {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days`,
emoji: true,
},
},
{
type: 'header',
text: {
type: 'plain_text',
text: '🧑‍💻 People & Companies',
emoji: true,
},
},
{
type: 'section',
text: {
type: 'plain_text',
text: peopleCreationSummary,
emoji: true,
},
},
{
type: 'header',
text: {
type: 'plain_text',
text: '🎯 Opportunities',
emoji: true,
},
},
{
type: 'section',
text: {
type: 'plain_text',
text: opportunityCreationSummary,
emoji: true,
},
},
{
type: 'header',
text: {
type: 'plain_text',
text: '📋 Tasks',
emoji: true,
},
},
{
type: 'section',
text: {
type: 'plain_text',
text: taskCreationSummary,
emoji: true,
},
},
],
};
const response = await fetch(process.env.SLACK_HOOK_URL ?? '', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slackMessage),
});
return {
formattedMesage: slackMessage,
webhookStatus: response.status,
};
};
export const sendToDiscord = async (params: {
peopleCreationSummary: string;
opportunityCreationSummary: string;
taskCreationSummary: string;
}) => {
const {
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
} = params;
const formattedMesage = `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days:
**🧑‍💻 People & Companies**
@@ -97,36 +101,35 @@ ${peopleCreationSummary}
${opportunityCreationSummary}
**📋 Tasks**
${taskCreationSummary}`
${taskCreationSummary}`;
const body = {
username: "Twenty Bot",
username: 'Twenty Bot',
content: formattedMesage,
}
};
const response = await fetch(process.env.DISCORD_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
const response = await fetch(process.env.DISCORD_WEBHOOK_URL ?? '', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
});
return {
formattedMesage,
webhookStatus: response.status,
}
}
};
};
export const sendToWhatsApp = async (params: {
peopleCreationSummary: string
opportunityCreationSummary: string
taskCreationSummary: string
peopleCreationSummary: string;
opportunityCreationSummary: string;
taskCreationSummary: string;
}): Promise<object> => {
const {
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
} = params
} = params;
const formattedMesage = `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days:
*🧑‍💻 People & Companies*
@@ -136,31 +139,34 @@ ${peopleCreationSummary}
${opportunityCreationSummary}
*📋 Tasks*
${taskCreationSummary}`
${taskCreationSummary}`;
const response = await fetch('https://graph.facebook.com/v22.0/828771160324576/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.FB_GRAPH_TOKEN}`
const response = await fetch(
'https://graph.facebook.com/v22.0/828771160324576/messages',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.FB_GRAPH_TOKEN}`,
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: process.env.WHATSAPP_RECIPIENT_PHONE_NUMBER,
type: 'text',
text: {
preview_url: true,
body: formattedMesage,
},
}),
},
body: JSON.stringify({
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": process.env.WHATSAPP_RECIPIENT_PHONE_NUMBER,
"type": "text",
"text": {
"preview_url": true,
"body": formattedMesage,
}
})
})
);
const responseBody = await response.json()
const responseBody = await response.json();
return {
formattedMesage,
webhookStatus: response.status,
webhookResponse: responseBody,
}
}
};
};
@@ -0,0 +1,26 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -14,9 +14,9 @@ __metadata:
languageName: node
linkType: hard
"Activity Summary@workspace:.":
"activity-summary@workspace:.":
version: 0.0.0-use.local
resolution: "Activity Summary@workspace:."
resolution: "activity-summary@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
twenty-sdk: "npm:0.0.3"
@@ -0,0 +1,30 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '028754f1-3235-43b9-9427-fa6a62dbd473',
displayName: 'AI Meeting Transcript',
description:
'Automatically process meeting transcripts to extract insights, action items, and follow-ups',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '1359d05c-4947-4673-809f-abd55bede365',
isSecret: true,
value: '',
description: 'Twenty API key',
},
TWENTY_API_URL: {
universalIdentifier: 'dbe83355-b574-445c-92c0-5c2b94a61ddb',
isSecret: true,
value: '',
description: 'Twenty API URL',
},
OPENAI_API_KEY: {
universalIdentifier: '9559470d-15eb-4bc2-9cbc-3bc5c869d1fd',
isSecret: true,
value: '',
description: 'OpenAI API key for transcript analysis',
},
},
};
export default config;
@@ -1,4 +1,5 @@
{
"name": "ai-meeting-transcript",
"version": "0.0.1",
"license": "MIT",
"engines": {
@@ -7,27 +8,6 @@
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "028754f1-3235-43b9-9427-fa6a62dbd473",
"name": "AI Meeting Transcript",
"description": "Automatically process meeting transcripts to extract insights, action items, and follow-ups",
"env": {
"TWENTY_API_KEY": {
"isSecret": true,
"value": "",
"description": "Twenty API key"
},
"TWENTY_API_URL": {
"isSecret": true,
"value": "",
"description": "Twenty API URL"
},
"OPENAI_API_KEY": {
"isSecret": true,
"value": "",
"description": "OpenAI API key for transcript analysis"
}
},
"dependencies": {
"axios": "^1.12.2",
"openai": "^4.28.0",
@@ -1,14 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "dae52ab2-174f-4f81-a031-604ee2e81eba",
"name": "ai-meeting-transcriptor",
"triggers": [
{
"universalIdentifier": "b011303d-2c24-44d4-9923-55eb060a1ff6",
"type": "route",
"path": "/webhook/transcript",
"httpMethod": "POST",
"isAuthRequired": false
}
]
}
@@ -1,5 +1,6 @@
import axios from 'axios';
import OpenAI from 'openai';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
type TranscriptWebhookPayload = {
transcript: string;
@@ -289,3 +290,17 @@ export const main = async (
},
};
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'dae52ab2-174f-4f81-a031-604ee2e81eba',
name: 'ai-meeting-transcriptor',
triggers: [
{
universalIdentifier: 'b011303d-2c24-44d4-9923-55eb060a1ff6',
type: 'route',
path: '/webhook/transcript',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};
@@ -17,9 +17,10 @@
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": ["node_modules", "dist"],
"include": ["**/*.ts"]
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -33,17 +33,6 @@ __metadata:
languageName: node
linkType: hard
"AI Meeting Transcript@workspace:.":
version: 0.0.0-use.local
resolution: "AI Meeting Transcript@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
axios: "npm:^1.12.2"
openai: "npm:^4.28.0"
twenty-sdk: "npm:^0.0.2"
languageName: unknown
linkType: soft
"abort-controller@npm:^3.0.0":
version: 3.0.0
resolution: "abort-controller@npm:3.0.0"
@@ -62,6 +51,17 @@ __metadata:
languageName: node
linkType: hard
"ai-meeting-transcript@workspace:.":
version: 0.0.0-use.local
resolution: "ai-meeting-transcript@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
axios: "npm:^1.12.2"
openai: "npm:^4.28.0"
twenty-sdk: "npm:^0.0.2"
languageName: unknown
linkType: soft
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
@@ -1,12 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/object.schema.json",
"universalIdentifier": "c8f4d3e1-2a7b-4e9f-8c1d-5a6b7e8f9c2a",
"standardId": "c8f4d3e1-2a7b-4e9f-8c1d-5a6b7e8f9c2a",
"nameSingular": "meeting",
"namePlural": "meetings",
"labelSingular": "Meeting",
"labelPlural": "Meetings",
"description": "Meetings imported from Fireflies with AI-generated summaries, sentiment, and action items.",
"icon": "IconVideo"
}
@@ -8,78 +8,6 @@
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"universalIdentifier": "af5128d7-192e-4bd3-bcf2-fec1ad767440",
"description": "Get fireflies meeting notes in Twenty",
"env": {
"FIREFLIES_API_KEY": {
"description": "Fireflies API key for authentication",
"isSecret": true
},
"FIREFLIES_WEBHOOK_SECRET": {
"description": "Secret key for validating Fireflies webhooks",
"isSecret": true
},
"FIREFLIES_PLAN_LEVEL": {
"description": "Fireflies plan level: free, pro, enterprise",
"value": "pro",
"isSecret": false
},
"TWENTY_API_KEY": {
"description": "Twenty CRM API key for authentication",
"isSecret": true
},
"SERVER_URL": {
"description": "Twenty CRM server URL",
"value": "http://localhost:3000",
"isSecret": false
},
"AUTO_CREATE_CONTACTS": {
"description": "Automatically create contacts for unknown participants (true/false)",
"value": "true",
"isSecret": false
},
"FIREFLIES_SUMMARY_STRATEGY": {
"description": "Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only",
"value": "immediate_with_retry",
"isSecret": false
},
"FIREFLIES_RETRY_ATTEMPTS": {
"description": "Number of retry attempts when fetching summaries",
"value": "30",
"isSecret": false
},
"FIREFLIES_RETRY_DELAY": {
"description": "Delay in milliseconds between retry attempts",
"value": "30000",
"isSecret": false
},
"FIREFLIES_POLL_INTERVAL": {
"description": "Polling interval (ms) when using delayed polling strategy",
"value": "60000",
"isSecret": false
},
"FIREFLIES_MAX_POLLS": {
"description": "Maximum number of polling attempts when waiting for summaries",
"value": "15",
"isSecret": false
},
"FIREFLIES_MAX_RETRY_ATTEMPTS": {
"description": "Maximum number of retry attempts when waiting for summaries",
"value": "30",
"isSecret": false
},
"FIREFLIES_MAX_POLL_INTERVAL": {
"description": "Maximum polling interval (ms) when waiting for summaries",
"value": "600000",
"isSecret": false
},
"DEBUG_LOGS": {
"description": "Enable debug logging (true/false)",
"value": "false",
"isSecret": false
}
},
"scripts": {
"test": "jest",
"setup:fields": "tsx scripts/add-meeting-fields.ts",
@@ -1,19 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "0765206a-a58d-4ecf-a5a4-23d1d2095f4e",
"name": "receive-fireflies-notes",
"description": "Receives Fireflies webhooks, fetches meeting summaries, and stores them in Twenty.",
"code": {
"src": "serverlessFunctions/receive-fireflies-notes/src/receive-fireflies-notes.ts"
},
"triggers": [
{
"universalIdentifier": "7742d477-4057-436d-9298-565f2934cf1a",
"type": "route",
"path": "/webhook/fireflies",
"httpMethod": "POST",
"isAuthRequired": true
}
]
}
@@ -1,10 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
@@ -15,8 +15,7 @@
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true
"resolveJsonModule": true
},
"exclude": [
"node_modules",
@@ -0,0 +1,24 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '718ed9ab-53fc-49c8-8deb-0cff78ecf0d2',
displayName: 'Last email interaction',
description:
'Updates Last interaction and Interaction status fields based on last received email',
applicationVariables: {
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',
},
},
};
export default config;
@@ -0,0 +1,14 @@
{
"name": "last-email-interaction",
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"dependencies": {
"axios": "^1.12.2"
}
}
@@ -1,5 +1,6 @@
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 ?? '';
const TWENTY_URL =
@@ -103,7 +104,7 @@ export const main = async (params: {
properties: Record<string, any>;
recordId: string;
userId: string;
}): Promise<object> => {
}): Promise<object | undefined> => {
if (TWENTY_API_KEY === '') {
console.log("Function exited as API key or URL hasn't been set properly");
return {};
@@ -121,22 +122,22 @@ export const main = async (params: {
const response = await axios.request(options);
const objects = response.data.data.objects;
const company_object = objects.find(
(object) => object.nameSingular === 'company',
(object: any) => object.nameSingular === 'company',
);
const company_last_interaction = company_object.fields.find(
(field) => field.name === 'lastInteraction',
(field: any) => field.name === 'lastInteraction',
);
const company_interaction_status = company_object.fields.find(
(field) => field.name === 'interactionStatus',
(field: any) => field.name === 'interactionStatus',
);
const person_object = objects.find(
(object) => object.nameSingular === 'person',
(object: any) => object.nameSingular === 'person',
);
const person_last_interaction = person_object.fields.find(
(field) => field.name === 'lastInteraction',
(field: any) => field.name === 'lastInteraction',
);
const person_interaction_status = person_object.fields.find(
(field) => field.name === 'interactionStatus',
(field: any) => field.name === 'interactionStatus',
);
// If not, create them
if (company_last_interaction === undefined) {
@@ -273,3 +274,20 @@ export const main = async (params: {
return {};
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '683966a0-b60a-424e-86b1-7448c9191bde',
name: 'test',
triggers: [
{
universalIdentifier: 'f4f1e127-87f0-4dcf-99fe-8061adf5cbe6',
type: 'databaseEvent',
eventName: 'message.created',
},
{
universalIdentifier: '4c17878f-b6b3-4d0a-8de6-967b1cb55002',
type: 'databaseEvent',
eventName: 'message.updated',
},
],
};
@@ -0,0 +1,26 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -1,29 +0,0 @@
{
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"universalIdentifier": "718ed9ab-53fc-49c8-8deb-0cff78ecf0d2",
"name": "Last email interaction",
"description": "Updates Last interaction and Interaction status fields based on last received email",
"env": {
"TWENTY_API_KEY": {
"isSecret": true,
"value": "",
"description": "Required to send requests to Twenty"
},
"TWENTY_API_URL": {
"isSecret": false,
"value": "",
"description": "Optional, defaults to cloud API URL"
}
},
"dependencies": {
"axios": "^1.12.2"
}
}
@@ -1,17 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json",
"universalIdentifier": "683966a0-b60a-424e-86b1-7448c9191bde",
"name": "test",
"triggers": [
{
"universalIdentifier": "f4f1e127-87f0-4dcf-99fe-8061adf5cbe6",
"type": "databaseEvent",
"eventName": "message.created"
},
{
"universalIdentifier": "4c17878f-b6b3-4d0a-8de6-967b1cb55002",
"type": "databaseEvent",
"eventName": "message.updated"
}
]
}
@@ -0,0 +1,23 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '627280a0-cb5b-40d3-a2e3-3e34b92926c8',
displayName: 'Browser Extension',
description: '',
applicationVariables: {
TWENTY_API_URL: {
universalIdentifier: '6cf6a57a-9708-4995-b6a5-65222ee1baf1',
isSecret: false,
value: '',
description: 'Twenty API URL',
},
TWENTY_API_KEY: {
universalIdentifier: '05d12575-e96e-4e45-b019-80dcdb67dc80',
isSecret: true,
value: '',
description: 'Twenty API Key',
},
},
};
export default config;
@@ -1,4 +1,5 @@
{
"name": "browser-extension",
"version": "0.0.1",
"license": "MIT",
"engines": {
@@ -9,21 +10,5 @@
"packageManager": "yarn@4.9.2",
"devDependencies": {
"@types/node": "^24.7.2"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "627280a0-cb5b-40d3-a2e3-3e34b92926c8",
"name": "Browser Extension",
"description": "",
"env": {
"TWENTY_API_URL": {
"isSecret": false,
"value": "",
"description": "Twenty API URL"
},
"TWENTY_API_KEY": {
"isSecret": true,
"value": "",
"description": "Twenty API Key"
}
}
}
@@ -1,14 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "cead3d1e-1fbd-4b09-86a9-f0bedf4d54fa",
"name": "create-company",
"triggers": [
{
"universalIdentifier": "57ff5ea2-c4b7-458c-9296-27bad6acdaf9",
"type": "route",
"path": "/create/company",
"httpMethod": "POST",
"isAuthRequired": true
}
]
}
@@ -1,6 +1,6 @@
export const main = async (params: {
name: string
}): Promise<object> => {
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (params: { name: string }): Promise<object> => {
const response = await fetch(`${process.env.TWENTY_API_URL}/rest/companies`, {
method: 'POST',
headers: {
@@ -8,7 +8,7 @@ export const main = async (params: {
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
body: JSON.stringify({
name: params.name
name: params.name,
}),
});
@@ -18,3 +18,17 @@ export const main = async (params: {
return (await response.json()) as object;
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'cead3d1e-1fbd-4b09-86a9-f0bedf4d54fa',
name: 'create-company',
triggers: [
{
universalIdentifier: '57ff5ea2-c4b7-458c-9296-27bad6acdaf9',
type: 'route',
path: '/create/company',
httpMethod: 'POST',
isAuthRequired: true,
},
],
};
@@ -1,14 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "7d38261b-99c5-43e7-83d8-bdcedc2dffdb",
"name": "create-person",
"triggers": [
{
"universalIdentifier": "ecf261b8-183b-4323-ab95-3b11009a0eae",
"type": "route",
"path": "/create/person",
"httpMethod": "POST",
"isAuthRequired": true
}
]
}
@@ -1,3 +1,5 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (params: {
firstName: string;
lastName: string;
@@ -22,3 +24,17 @@ export const main = async (params: {
return (await response.json()) as object;
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '7d38261b-99c5-43e7-83d8-bdcedc2dffdb',
name: 'create-person',
triggers: [
{
universalIdentifier: 'ecf261b8-183b-4323-ab95-3b11009a0eae',
type: 'route',
path: '/create/person',
httpMethod: 'POST',
isAuthRequired: true,
},
],
};
@@ -1,14 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "8e43b96b-49a1-4e21-b257-e432a757b09f",
"name": "get-company",
"triggers": [
{
"universalIdentifier": "7a2bb8ad-6366-49ac-9f73-db9c4713c5af",
"type": "route",
"path": "/get/company",
"httpMethod": "GET",
"isAuthRequired": true
}
]
}
@@ -1,3 +1,5 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (params: {
a: string;
b: number;
@@ -8,7 +10,19 @@ export const main = async (params: {
// This is just an example
const message = `Hello, input: ${a} and ${b}`;
return { message };
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '8e43b96b-49a1-4e21-b257-e432a757b09f',
name: 'get-company',
triggers: [
{
universalIdentifier: '7a2bb8ad-6366-49ac-9f73-db9c4713c5af',
type: 'route',
path: '/get/company',
httpMethod: 'GET',
isAuthRequired: true,
},
],
};
@@ -1,14 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "87ea9816-c9e5-4860-b49f-a5f0759800f7",
"name": "get-person",
"triggers": [
{
"universalIdentifier": "54aec609-0518-4fb0-bd90-7cd21507fe11",
"type": "route",
"path": "/get/person",
"httpMethod": "GET",
"isAuthRequired": true
}
]
}
@@ -1,3 +1,5 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (params: {
a: string;
b: number;
@@ -8,7 +10,19 @@ export const main = async (params: {
// This is just an example
const message = `Hello, input: ${a} and ${b}`;
return { message };
};
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '87ea9816-c9e5-4860-b49f-a5f0759800f7',
name: 'get-person',
triggers: [
{
universalIdentifier: '54aec609-0518-4fb0-bd90-7cd21507fe11',
type: 'route',
path: '/get/person',
httpMethod: 'GET',
isAuthRequired: true,
},
],
};
@@ -0,0 +1,26 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -0,0 +1,30 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__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
"browser-extension@workspace:.":
version: 0.0.0-use.local
resolution: "browser-extension@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
languageName: unknown
linkType: soft
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
@@ -0,0 +1,67 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '1eadac4e-db9f-4cce-b20b-de75f41e34dc',
displayName: 'Mailchimp synchronizer',
description: '',
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: '',
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: {
universalIdentifier: '6c8b6ac9-dd45-4f0b-a397-c4a38edccfd9',
value: '',
description: 'Required to send requests to Mailchimp',
},
IS_EMAIL_CONSTRAINT: {
universalIdentifier: '62626c57-470f-4866-be1e-5b4d7ec09f9f',
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',
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',
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',
value: 'false',
description:
'Set to true if you want to add additional constraint (default is false)',
},
UPDATE_PERSON: {
universalIdentifier: '9d753e1e-4408-40ca-b0f0-5c7e8625c2aa',
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',
},
},
};
export default config;
@@ -1,4 +1,5 @@
{
"name": "mailchimp-synchronizer",
"version": "0.0.1",
"license": "MIT",
"engines": {
@@ -7,54 +8,6 @@
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"universalIdentifier": "1eadac4e-db9f-4cce-b20b-de75f41e34dc",
"name": "Mailchimp synchronizer",
"description": "",
"env": {
"TWENTY_API_KEY": {
"isSecret": true,
"value": "",
"description": "Required to send requests to Twenty"
},
"TWENTY_API_URL": {
"value": "",
"description": "Optional, defaults to cloud API URL"
},
"MAILCHIMP_API_KEY": {
"isSecret": true,
"value": "",
"description": "Required to send requests to Mailchimp"
},
"MAILCHIMP_URL": {
"value": "",
"description": "Required to send requests to Mailchimp"
},
"IS_EMAIL_CONSTRAINT": {
"value": "false",
"description": "Set to true if you want to add additional constraint (default is false)"
},
"IS_PHONE_CONSTRAINT": {
"value": "false",
"description": "Set to true if you want to add additional constraint (default is false)"
},
"IS_COMPANY_CONSTRAINT": {
"value": "false",
"description": "Set to true if you want to add additional constraint (default is false)"
},
"IS_ADDRESS_CONSTRAINT": {
"value": "false",
"description": "Set to true if you want to add additional constraint (default is false)"
},
"UPDATE_PERSON": {
"value": "false",
"description": "Set to true if you want to update record if it exists"
},
"MAILCHIMP_AUDIENCE_ID": {
"value": "",
"description": "Required to send requests to Mailchimp"
}
},
"dependencies": {
"axios": "^1.13.1"
}
@@ -1,12 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json",
"universalIdentifier": "83319670-775b-4862-b133-5c353e594151",
"name": "mailchimp-synchronizer",
"triggers": [
{
"universalIdentifier": "e627ff6f-0a0c-48b2-bdbb-31967489ec96",
"type": "databaseEvent",
"eventName": "person.created"
}
]
}
@@ -1,4 +1,5 @@
import axios from 'axios';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
const TWENTY_API_URL: string =
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
@@ -119,9 +120,9 @@ const prepareData = (
phoneNumber: string,
phoneCallingCode: string,
companyName: string,
address: mailchimpAddress,
address: mailchimpAddress | null,
): object => {
const data = {};
const data = {} as any;
if (IS_EMAIL_CONSTRAINT) {
data['email_channel'] = {
email: email,
@@ -194,11 +195,11 @@ export const main = async (params: {
IS_PHONE_CONSTRAINT &&
twentyRecord.phones.primaryPhoneNumber !== '' &&
twentyRecord.phones.primaryPhoneCallingCode !== '';
const company: object = IS_COMPANY_CONSTRAINT
const company: any = IS_COMPANY_CONSTRAINT
? await fetchCompanyData(properties.after.companyId)
: null;
const companyName: string = company['name'] !== '' ? company['name'] : null;
const address: mailchimpAddress = IS_ADDRESS_CONSTRAINT
const address: mailchimpAddress | null = IS_ADDRESS_CONSTRAINT
? checkAddress(company['address'])
: null;
@@ -265,3 +266,15 @@ export const main = async (params: {
return {};
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '83319670-775b-4862-b133-5c353e594151',
name: 'mailchimp-synchronizer',
triggers: [
{
universalIdentifier: 'e627ff6f-0a0c-48b2-bdbb-31967489ec96',
type: 'databaseEvent',
eventName: 'person.created',
},
],
};
@@ -0,0 +1,26 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -5,14 +5,6 @@ __metadata:
version: 8
cacheKey: 10c0
"Mailchimp synchronizer@workspace:.":
version: 0.0.0-use.local
resolution: "Mailchimp synchronizer@workspace:."
dependencies:
axios: "npm:^1.13.1"
languageName: unknown
linkType: soft
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
@@ -217,6 +209,14 @@ __metadata:
languageName: node
linkType: hard
"mailchimp-synchronizer@workspace:.":
version: 0.0.0-use.local
resolution: "mailchimp-synchronizer@workspace:."
dependencies:
axios: "npm:^1.13.1"
languageName: unknown
linkType: soft
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
@@ -1,54 +0,0 @@
{
"version": "0.0.1",
"type": "module",
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || ^22.0.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.6.0"
},
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "f15a0c72-f7b4-4d20-9e97-ade1122d4bd7",
"name": "Meeting Transcript",
"description": "AI Meeting Transcript Integration for Hacktoberfest",
"env": {
"TWENTY_API_KEY": {
"isSecret": true,
"value": "",
"description": "API key for the Twenty CRM instance (used for authentication)."
},
"AI_PROVIDER_API_KEY": {
"isSecret": true,
"value": "",
"description": "API key for authenticating with the OpenAI-compatible service (supports OpenAI, Groq, and other providers)."
},
"TWENTY_API_URL": {
"isSecret": false,
"value": "https://unpaid-interns.twenty.com",
"description": "The base URL for the Twenty CRM server (e.g., https://your-instance.twenty.com)."
},
"WEBHOOK_SECRET_TOKEN": {
"isSecret": true,
"value": "",
"description": "Secret token used to authenticate incoming webhook requests."
},
"AI_PROVIDER_API_BASE_URL": {
"isSecret": false,
"value": "https://api.openai.com/v1",
"description": "Base URL for OpenAI-compatible API. Defaults to OpenAI, but can be changed to use Groq (https://api.groq.com/openai/v1) or other providers."
}
},
"dependencies": {
"axios": "^1.7.0",
"openai": "^4.67.0"
}
}
@@ -1,14 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "b5e86982-b9ec-4c3d-8a02-6ea08a5b2d35",
"name": "process-transcript",
"triggers": [
{
"universalIdentifier": "a25fcbbf-1a20-438a-b277-ee8ca9770499",
"type": "route",
"path": "/transcript",
"httpMethod": "POST",
"isAuthRequired": true
}
]
}
@@ -1,36 +0,0 @@
{
"compilerOptions": {
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
"module": "nodenext",
"target": "esnext",
"moduleResolution": "nodenext",
// <-- add this
"types": ["node"], // Node types
"lib": ["esnext"], // Modern JS libs
"esModuleInterop": true, // <-- add this for axios & other CJS imports
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true
},
"include": ["**/*.ts", "**/*.tsx"]
}
@@ -0,0 +1,46 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: 'f15a0c72-f7b4-4d20-9e97-ade1122d4bd7',
displayName: 'Meeting Transcript',
description: 'AI Meeting Transcript Integration for Hacktoberfest',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'c5a4310b-6744-4fda-ad0a-d1c6fea0539b',
isSecret: true,
value: '',
description:
'API key for the Twenty CRM instance (used for authentication).',
},
AI_PROVIDER_API_KEY: {
universalIdentifier: '7b0f965e-0192-41b5-b390-45a7e5a761b8',
isSecret: true,
value: '',
description:
'API key for authenticating with the OpenAI-compatible service (supports OpenAI, Groq, and other providers).',
},
TWENTY_API_URL: {
universalIdentifier: '84311303-1220-440c-a4fb-0be2d74d267b',
isSecret: false,
value: 'https://unpaid-interns.twenty.com',
description:
'The base URL for the Twenty CRM server (e.g., https://your-instance.twenty.com).',
},
WEBHOOK_SECRET_TOKEN: {
universalIdentifier: '187c39c9-8e2a-4086-94b3-59935d4e1a93',
isSecret: true,
value: '',
description:
'Secret token used to authenticate incoming webhook requests.',
},
AI_PROVIDER_API_BASE_URL: {
universalIdentifier: '15974ed8-4efb-4ebc-9f53-5b0b36183fc4',
isSecret: false,
value: 'https://api.openai.com/v1',
description:
'Base URL for OpenAI-compatible API. Defaults to OpenAI, but can be changed to use Groq (https://api.groq.com/openai/v1) or other providers.',
},
},
};
export default config;
@@ -0,0 +1,24 @@
{
"name": "meeting-transcript",
"version": "0.0.1",
"type": "module",
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || ^22.0.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.6.0"
},
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.7.0",
"openai": "^4.67.0"
}
}
@@ -1,5 +1,6 @@
import axios from 'axios';
import OpenAI from 'openai';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
type TranscriptWebhookPayload = {
transcript: string;
@@ -91,16 +92,12 @@ const lookupWorkspaceMemberByName = async (
`,
};
const response = await axios.post(
`${baseUrl}/graphql`,
graphqlQuery,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
const response = await axios.post(`${baseUrl}/graphql`, graphqlQuery, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
);
});
const edges = response.data?.data?.workspaceMembers?.edges;
@@ -117,7 +114,9 @@ const lookupWorkspaceMemberByName = async (
const fullName = `${firstName} ${lastName}`.trim().toLowerCase();
if (fullName === searchName) {
console.log(`✅ Found workspace member (exact): ${firstName} ${lastName} (ID: ${edge.node.id})`);
console.log(
`✅ Found workspace member (exact): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
@@ -126,7 +125,9 @@ const lookupWorkspaceMemberByName = async (
const firstName = edge.node.name?.firstName || '';
if (firstName.toLowerCase() === searchName) {
const lastName = edge.node.name?.lastName || '';
console.log(`✅ Found workspace member (first name): ${firstName} ${lastName} (ID: ${edge.node.id})`);
console.log(
`✅ Found workspace member (first name): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
@@ -135,7 +136,9 @@ const lookupWorkspaceMemberByName = async (
const lastName = edge.node.name?.lastName || '';
if (lastName.toLowerCase() === searchName) {
const firstName = edge.node.name?.firstName || '';
console.log(`✅ Found workspace member (last name): ${firstName} ${lastName} (ID: ${edge.node.id})`);
console.log(
`✅ Found workspace member (last name): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
@@ -146,7 +149,9 @@ const lookupWorkspaceMemberByName = async (
const fullName = `${firstName} ${lastName}`.trim().toLowerCase();
if (fullName.includes(searchName) || searchName.includes(fullName)) {
console.log(`✅ Found workspace member (partial): ${firstName} ${lastName} (ID: ${edge.node.id})`);
console.log(
`✅ Found workspace member (partial): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
@@ -158,15 +163,15 @@ const lookupWorkspaceMemberByName = async (
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message;
console.error(`❌ Failed to lookup workspace member "${name}": ${errorMessage}`);
console.error(
`❌ Failed to lookup workspace member "${name}": ${errorMessage}`,
);
}
return null;
}
};
const lookupPersonByName = async (
name: string,
): Promise<string | null> => {
const lookupPersonByName = async (name: string): Promise<string | null> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
try {
@@ -188,16 +193,12 @@ const lookupPersonByName = async (
`,
};
const response = await axios.post(
`${baseUrl}/graphql`,
graphqlQuery,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
const response = await axios.post(`${baseUrl}/graphql`, graphqlQuery, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
);
});
const edges = response.data?.data?.people?.edges;
@@ -214,7 +215,9 @@ const lookupPersonByName = async (
const fullName = `${firstName} ${lastName}`.trim().toLowerCase();
if (fullName === searchName) {
console.log(`✅ Found person (exact): ${firstName} ${lastName} (ID: ${edge.node.id})`);
console.log(
`✅ Found person (exact): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
@@ -223,7 +226,9 @@ const lookupPersonByName = async (
const firstName = edge.node.name?.firstName || '';
if (firstName.toLowerCase() === searchName) {
const lastName = edge.node.name?.lastName || '';
console.log(`✅ Found person (first name): ${firstName} ${lastName} (ID: ${edge.node.id})`);
console.log(
`✅ Found person (first name): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
@@ -232,7 +237,9 @@ const lookupPersonByName = async (
const lastName = edge.node.name?.lastName || '';
if (lastName.toLowerCase() === searchName) {
const firstName = edge.node.name?.firstName || '';
console.log(`✅ Found person (last name): ${firstName} ${lastName} (ID: ${edge.node.id})`);
console.log(
`✅ Found person (last name): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
@@ -243,7 +250,9 @@ const lookupPersonByName = async (
const fullName = `${firstName} ${lastName}`.trim().toLowerCase();
if (fullName.includes(searchName) || searchName.includes(fullName)) {
console.log(`✅ Found person (partial): ${firstName} ${lastName} (ID: ${edge.node.id})`);
console.log(
`✅ Found person (partial): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
@@ -261,7 +270,10 @@ const lookupPersonByName = async (
}
};
const extractPersonNamesFromDescription = (description: string, participants: string[]): string[] => {
const extractPersonNamesFromDescription = (
description: string,
participants: string[],
): string[] => {
const foundNames: string[] = [];
for (const participant of participants) {
@@ -308,7 +320,9 @@ const linkNoteToPersonREST = async (
const noteTargetId = response.data?.data?.createNoteTarget?.id;
if (noteTargetId) {
console.log(`✅ Successfully linked note ${noteId} to person ${personId} (noteTarget: ${noteTargetId})`);
console.log(
`✅ Successfully linked note ${noteId} to person ${personId} (noteTarget: ${noteTargetId})`,
);
} else {
console.warn(`⚠️ Note linking response received but no ID found`);
}
@@ -318,8 +332,12 @@ const linkNoteToPersonREST = async (
? JSON.stringify(error.response.data, null, 2)
: error.message;
const status = error.response?.status;
console.error(`❌ Failed to link note to person. Status: ${status}, Error: ${errorMessage}`);
console.error(`Attempted to link noteId: ${noteId} to personId: ${personId}`);
console.error(
`❌ Failed to link note to person. Status: ${status}, Error: ${errorMessage}`,
);
console.error(
`Attempted to link noteId: ${noteId} to personId: ${personId}`,
);
throw new Error(`Failed to link note to person: ${errorMessage}`);
}
throw error;
@@ -348,16 +366,12 @@ const createNoteInTwenty = async (
};
try {
const response = await axios.post(
`${baseUrl}/rest/notes`,
requestData,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
const response = await axios.post(`${baseUrl}/rest/notes`, requestData, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
);
});
const responseJson = JSON.stringify(response.data);
console.log('📦 Note API Response:', responseJson);
@@ -409,15 +423,22 @@ const linkTaskToPersonREST = async (
},
},
);
console.log(`✅ Successfully linked task ${taskId} to person ${personId}`, response.data);
console.log(
`✅ Successfully linked task ${taskId} to person ${personId}`,
response.data,
);
} catch (error) {
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message;
const status = error.response?.status;
console.error(`❌ Failed to link task to person. Status: ${status}, Error: ${errorMessage}`);
console.error(`Attempted to link taskId: ${taskId} to personId: ${personId}`);
console.error(
`❌ Failed to link task to person. Status: ${status}, Error: ${errorMessage}`,
);
console.error(
`Attempted to link taskId: ${taskId} to personId: ${personId}`,
);
}
}
};
@@ -446,9 +467,13 @@ const createTaskInTwenty = async (
const assigneeId = await lookupWorkspaceMemberByName(actionItem.assignee);
if (assigneeId) {
taskData.assigneeId = assigneeId;
console.log(`✅ Task will be assigned to: ${actionItem.assignee} (${assigneeId})`);
console.log(
`✅ Task will be assigned to: ${actionItem.assignee} (${assigneeId})`,
);
} else {
console.log(`⚠️ Could not find workspace member "${actionItem.assignee}", task will be unassigned`);
console.log(
`⚠️ Could not find workspace member "${actionItem.assignee}", task will be unassigned`,
);
}
}
@@ -460,27 +485,28 @@ const createTaskInTwenty = async (
}
try {
const response = await axios.post(
`${baseUrl}/rest/tasks`,
taskData,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
const response = await axios.post(`${baseUrl}/rest/tasks`, taskData, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
);
});
console.log('📦 Task API Response:', JSON.stringify(response.data));
const taskId = response.data?.data?.createTask?.id;
if (!taskId) {
console.error('❌ Failed to extract task ID from response:', response.data);
console.error(
'❌ Failed to extract task ID from response:',
response.data,
);
throw new Error('Task created but ID not found in response');
}
console.log(`✅ Task created successfully: ${taskId} - "${actionItem.title}"`);
console.log(
`✅ Task created successfully: ${taskId} - "${actionItem.title}"`,
);
if (relatedPersonId) {
await linkTaskToPersonREST(taskId, relatedPersonId);
@@ -493,7 +519,9 @@ const createTaskInTwenty = async (
? JSON.stringify(error.response.data, null, 2)
: error.message;
const status = error.response?.status;
console.error(`❌ Failed to create task "${actionItem.title}". Status: ${status}, Error: ${errorMessage}`);
console.error(
`❌ Failed to create task "${actionItem.title}". Status: ${status}, Error: ${errorMessage}`,
);
throw new Error(
`Failed to create task "${actionItem.title}": ${errorMessage}. Status: ${status}`,
);
@@ -515,7 +543,10 @@ const createTasksFromActionItems = async (
const taskDescription = `${actionItem.description}\n\n*Related to meeting note: ${noteId}*`;
console.log(`Creating task: "${actionItem.title}"`);
const mentionedPeople = extractPersonNamesFromDescription(actionItem.description, participants);
const mentionedPeople = extractPersonNamesFromDescription(
actionItem.description,
participants,
);
console.log(`📝 People mentioned in task description:`, mentionedPeople);
const task = await createTaskInTwenty({
@@ -531,17 +562,24 @@ const createTasksFromActionItems = async (
if (personId) {
await linkTaskToPersonREST(task.id, personId);
} else {
console.log(`⚠️ Could not find person "${personName}" in CRM, skipping link`);
console.log(
`⚠️ Could not find person "${personName}" in CRM, skipping link`,
);
}
}
} else {
console.log(`⚠️ No specific people mentioned, using relatedPersonId as fallback`);
console.log(
`⚠️ No specific people mentioned, using relatedPersonId as fallback`,
);
await linkTaskToPersonREST(task.id, relatedPersonId);
}
console.log(`✅ Task linking complete: ${task.id}`);
} catch (error) {
console.error(`❌ Task creation failed for "${actionItem.title}":`, error instanceof Error ? error.message : error);
console.error(
`❌ Task creation failed for "${actionItem.title}":`,
error instanceof Error ? error.message : error,
);
}
}
@@ -573,7 +611,10 @@ const createTasksFromCommitments = async (
await linkTaskToPersonREST(task.id, relatedPersonId);
}
} catch (error) {
console.error(`Commitment task creation failed for "${commitment.commitment}":`, error);
console.error(
`Commitment task creation failed for "${commitment.commitment}":`,
error,
);
}
}
@@ -696,7 +737,9 @@ ${transcript}`;
try {
parsedResult = JSON.parse(content) as AnalysisResult;
} catch (error) {
throw new Error(`Failed to parse AI response as JSON: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new Error(
`Failed to parse AI response as JSON: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
if (!parsedResult.summary || typeof parsedResult.summary !== 'string') {
@@ -732,7 +775,13 @@ export const main = async (
throw new Error('Unauthorized webhook access: Invalid or missing token.');
}
const { transcript, meetingTitle, meetingDate, relatedPersonId, participants } = params;
const {
transcript,
meetingTitle,
meetingDate,
relatedPersonId,
participants,
} = params;
if (!transcript || typeof transcript !== 'string') {
throw new Error('Transcript is required and must be a string');
@@ -752,7 +801,9 @@ export const main = async (
log('🤖 Starting transcript analysis...');
const analysis = await analyzeTranscript(transcript, openaiApiKey);
log(`✅ Analysis complete: ${analysis.actionItems.length} action items, ${analysis.commitments.length} commitments`);
log(
`✅ Analysis complete: ${analysis.actionItems.length} action items, ${analysis.commitments.length} commitments`,
);
log('📄 Creating note in Twenty CRM...');
const note = await createNoteInTwenty(
@@ -804,4 +855,18 @@ export const main = async (
executionLogs: executionLogs,
};
}
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'b5e86982-b9ec-4c3d-8a02-6ea08a5b2d35',
name: 'process-transcript',
triggers: [
{
universalIdentifier: 'a25fcbbf-1a20-438a-b277-ee8ca9770499',
type: 'route',
path: '/transcript',
httpMethod: 'POST',
isAuthRequired: true,
},
],
};
@@ -0,0 +1,26 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -42,17 +42,6 @@ __metadata:
languageName: node
linkType: hard
"Meeting Transcript@workspace:.":
version: 0.0.0-use.local
resolution: "Meeting Transcript@workspace:."
dependencies:
"@types/node": "npm:^20.0.0"
axios: "npm:^1.7.0"
openai: "npm:^4.67.0"
typescript: "npm:^5.6.0"
languageName: unknown
linkType: soft
"abort-controller@npm:^3.0.0":
version: 3.0.0
resolution: "abort-controller@npm:3.0.0"
@@ -315,6 +304,17 @@ __metadata:
languageName: node
linkType: hard
"meeting-transcript@workspace:.":
version: 0.0.0-use.local
resolution: "meeting-transcript@workspace:."
dependencies:
"@types/node": "npm:^20.0.0"
axios: "npm:^1.7.0"
openai: "npm:^4.67.0"
typescript: "npm:^5.6.0"
languageName: unknown
linkType: soft
"mime-db@npm:1.52.0":
version: 1.52.0
resolution: "mime-db@npm:1.52.0"
@@ -0,0 +1,32 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '2b308f8c-6ff8-4838-9880-aa0271dfd8d8',
displayName: 'Rollup engine',
description: 'Rollup engine',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '1dc3356a-f660-4097-a161-b1686ad00c74',
isSecret: true,
value: '',
description:
'Workspace API key used by the rollup engine to call the Twenty REST API.',
},
TWENTY_API_BASE_URL: {
universalIdentifier: '274c512c-a870-4651-9617-2638e0def14c',
isSecret: false,
value: '',
description:
'Optional override for the REST base URL (defaults to https://app.twenty.com/rest).',
},
ROLLUP_ENGINE_CONFIG: {
universalIdentifier: 'a4672cd9-4081-43af-9d3b-5a8a55a72613',
isSecret: false,
value: '',
description:
'Optional JSON override for rollup definitions. Leave blank to use the baked-in defaults.',
},
},
};
export default config;
@@ -1,4 +1,5 @@
{
"name": "rollup-engine",
"version": "0.0.1",
"license": "MIT",
"engines": {
@@ -7,27 +8,6 @@
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"universalIdentifier": "2b308f8c-6ff8-4838-9880-aa0271dfd8d8",
"name": "Rollup engine",
"description": "Rollup engine",
"env": {
"TWENTY_API_KEY": {
"isSecret": true,
"value": "",
"description": "Workspace API key used by the rollup engine to call the Twenty REST API."
},
"TWENTY_API_BASE_URL": {
"isSecret": false,
"value": "",
"description": "Optional override for the REST base URL (defaults to https://app.twenty.com/rest)."
},
"ROLLUP_ENGINE_CONFIG": {
"isSecret": false,
"value": "",
"description": "Optional JSON override for rollup definitions. Leave blank to use the baked-in defaults."
}
},
"scripts": {
"smoke": "node --import tsx scripts/run-rollup-smoke.ts",
"setup:metadata": "node scripts/setup-company-rollup-fields.mjs"
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { main as runRollups } from '../serverlessFunctions/calculaterollups/src/index.ts';
import { main as runRollups } from '../serverlessFunctions/calculaterollups/src/index';
type Json = Record<string, unknown>;
@@ -48,7 +48,7 @@ const jsonResponse = (data: Json | Json[]) =>
});
global.fetch = async (
rawUrl: string | URL,
rawUrl: any,
init?: { method?: string; body?: unknown },
): Promise<any> => {
const url = typeof rawUrl === 'string' ? new URL(rawUrl) : rawUrl;
@@ -62,7 +62,9 @@ global.fetch = async (
if (url.pathname.endsWith('/opportunities') && method === 'GET') {
const companyId = url.searchParams.get('filter[companyId]');
const items = companyId
? mockOpportunities.filter((opportunity) => opportunity.companyId === companyId)
? mockOpportunities.filter(
(opportunity) => opportunity.companyId === companyId,
)
: mockOpportunities;
return jsonResponse({
data: {
@@ -81,10 +83,12 @@ global.fetch = async (
return jsonResponse({ data: { companies: [{ id, ...payload }] } });
}
throw new Error(`Unhandled request in mock fetch: ${method} ${url.toString()}`);
throw new Error(
`Unhandled request in mock fetch: ${method} ${url.toString()}`,
);
};
function safeParse(body: unknown) {
const safeParse = (body: unknown) => {
if (!body) {
return undefined;
}
@@ -97,9 +101,9 @@ function safeParse(body: unknown) {
}
}
return undefined;
}
};
async function main() {
const main = async () => {
process.env.TWENTY_API_KEY = 'mock-api-key';
process.env.TWENTY_API_BASE_URL = 'https://mock.twenty/api';
@@ -123,15 +127,22 @@ async function main() {
lastOpportunityCloseDate: `${currentYear.toString().padStart(4, '0')}-03-05`,
};
const companyUpdate = updatePayloads.find((payload) => payload.id === 'company-1');
const companyUpdate = updatePayloads.find(
(payload) => payload.id === 'company-1',
);
assert(companyUpdate, 'expected a PATCH payload for company-1');
assert.deepStrictEqual(companyUpdate.payload, expectedPayload);
const opportunitiesRequest = requestLog.find(
(entry) => entry.method === 'GET' && entry.url.includes('/opportunities'),
);
assert(opportunitiesRequest, 'expected at least one GET request for /opportunities');
const filterParams = new URL(opportunitiesRequest.url).searchParams.getAll('filter');
assert(
opportunitiesRequest,
'expected at least one GET request for /opportunities',
);
const filterParams = new URL(opportunitiesRequest.url).searchParams.getAll(
'filter',
);
assert.deepStrictEqual(
filterParams,
['companyId[eq]:"company-1"'],
@@ -146,7 +157,7 @@ async function main() {
console.log('\n--- Requests made ---');
console.dir(requestLog, { depth: null });
}
};
main().catch((error) => {
console.error('Smoke test failed', error);
@@ -1,24 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json",
"universalIdentifier": "c3ec36c8-5b1d-421f-9172-a9e035ab9c18",
"name": "calculaterollups",
"triggers": [
{
"universalIdentifier": "eec8aaf2-b0cc-47fd-b522-8d4aa5fe4bd3",
"type": "databaseEvent",
"eventName": "opportunity.*"
},
{
"universalIdentifier": "a3fea230-1121-44a6-b395-5811c3031f8e",
"type": "cron",
"schedule": " 0 2 * * *"
},
{
"universalIdentifier": "d33b0fe4-4b2b-45c0-aa2f-e617fdbba484",
"type": "route",
"path": "/recalculate-all",
"httpMethod": "POST",
"isAuthRequired": true
}
]
}
@@ -10,7 +10,10 @@ export const computeAggregations = (
now: Date,
) => {
const baseFiltered = applyFilters(records, definition.childFilters, now);
const result: Record<string, number | string | null | Record<string, unknown>> = {};
const result: Record<
string,
number | string | null | Record<string, unknown>
> = {};
definition.aggregations.forEach((aggregation) => {
const scopedRecords = applyFilters(baseFiltered, aggregation.filters, now);
@@ -40,7 +43,7 @@ export const computeAggregations = (
return accumulator;
}
return {
amount: accumulator.amount + numeric,
amount: (accumulator.amount as number) + numeric,
currency:
typeof currencyRaw === 'string' && currencyRaw.trim().length > 0
? currencyRaw
@@ -50,7 +53,7 @@ export const computeAggregations = (
{ amount: 0, currency: undefined as string | undefined },
);
result[aggregation.parentField] = {
amountMicros: Math.round(roundForSum(total.amount)),
amountMicros: Math.round(roundForSum(total.amount as number)),
currencyCode: total.currency ?? '',
};
break;
@@ -72,46 +75,58 @@ export const computeAggregations = (
return accumulator;
}
return {
total: accumulator.total + numeric,
count: accumulator.count + 1,
total: (accumulator.total as number) + numeric,
count: (accumulator.count as number) + 1,
};
},
{ total: 0, count: 0 },
);
result[aggregation.parentField] = count === 0 ? null : roundForSum(total / count);
result[aggregation.parentField] =
count === 0
? null
: roundForSum((total as number) / (count as number));
break;
}
case 'MAX':
case 'MIN': {
if (!aggregation.childField) {
throw new Error(`${aggregation.type} aggregation requires childField`);
const childField = aggregation.childField;
if (!childField) {
throw new Error(
`${aggregation.type} aggregation requires childField`,
);
}
const direction = aggregation.type === 'MAX' ? 1 : -1;
let chosen: { raw: unknown; comparable: number | null } | null = null;
scopedRecords.forEach((record) => {
const rawValue = getNestedValue(record, aggregation.childField!);
let chosen: { raw: unknown; comparable: number } | null = null;
for (const record of scopedRecords) {
const rawValue = getNestedValue(record, childField);
const comparable = toComparableNumber(rawValue);
if (comparable === null) {
return;
}
if (comparable === null) continue;
if (
chosen === null ||
(chosen.comparable !== null && direction * (comparable - chosen.comparable) > 0)
direction * (comparable - chosen.comparable) > 0
) {
chosen = { raw: rawValue, comparable };
}
});
}
if (chosen === null) {
result[aggregation.parentField] = null;
} else if (typeof chosen.raw === 'number') {
result[aggregation.parentField] = chosen.raw;
} else if (chosen.raw instanceof Date) {
result[aggregation.parentField] = chosen.raw.toISOString().slice(0, 10);
} else if (chosen.raw === null || chosen.raw === undefined) {
break;
}
const raw = chosen.raw;
if (typeof raw === 'number') {
result[aggregation.parentField] = raw;
} else if (raw instanceof Date) {
result[aggregation.parentField] = raw.toISOString().slice(0, 10);
} else if (raw == null) {
result[aggregation.parentField] = null;
} else {
const rawString = String(chosen.raw);
const rawString = String(raw);
const parsed = Date.parse(rawString);
result[aggregation.parentField] = Number.isNaN(parsed)
? rawString
@@ -28,18 +28,27 @@ const aggregationTypes = new Set<AggregationConfig['type']>([
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
export const validateRollupConfig = (config: unknown): asserts config is RollupConfig => {
export const validateRollupConfig = (config: any): RollupConfig | void => {
if (!Array.isArray(config)) {
throw new Error('Rollup configuration must contain an array of rollup definitions');
throw new Error(
'Rollup configuration must contain an array of rollup definitions',
);
}
config.forEach((definition, definitionIndex) => {
if (!isObject(definition)) {
throw new Error(`Definition at index ${definitionIndex} must be an object`);
throw new Error(
`Definition at index ${definitionIndex} must be an object`,
);
}
const { parentObject, childObject, relationField, childFilters, aggregations } =
definition as Partial<RollupDefinition>;
const {
parentObject,
childObject,
relationField,
childFilters,
aggregations,
} = definition as Partial<RollupDefinition>;
if (typeof parentObject !== 'string' || parentObject.trim().length === 0) {
throw new Error(`Definition ${definitionIndex} missing parentObject`);
@@ -47,11 +56,16 @@ export const validateRollupConfig = (config: unknown): asserts config is RollupC
if (typeof childObject !== 'string' || childObject.trim().length === 0) {
throw new Error(`Definition ${definitionIndex} missing childObject`);
}
if (typeof relationField !== 'string' || relationField.trim().length === 0) {
if (
typeof relationField !== 'string' ||
relationField.trim().length === 0
) {
throw new Error(`Definition ${definitionIndex} missing relationField`);
}
if (!Array.isArray(aggregations) || aggregations.length === 0) {
throw new Error(`Definition ${definitionIndex} must declare at least one aggregation`);
throw new Error(
`Definition ${definitionIndex} must declare at least one aggregation`,
);
}
const filtersToValidate = [
@@ -63,7 +77,8 @@ export const validateRollupConfig = (config: unknown): asserts config is RollupC
);
}
const { type, parentField, childField, filters } = aggregation as AggregationConfig;
const { type, parentField, childField, filters } =
aggregation as AggregationConfig;
if (!aggregationTypes.has(type)) {
throw new Error(
@@ -71,13 +86,19 @@ export const validateRollupConfig = (config: unknown): asserts config is RollupC
);
}
if (typeof parentField !== 'string' || parentField.trim().length === 0) {
if (
typeof parentField !== 'string' ||
parentField.trim().length === 0
) {
throw new Error(
`Aggregation ${aggregationIndex} in definition ${definitionIndex} missing parentField`,
);
}
if (type !== 'COUNT' && (typeof childField !== 'string' || childField.length === 0)) {
if (
type !== 'COUNT' &&
(typeof childField !== 'string' || childField.length === 0)
) {
throw new Error(
`Aggregation ${aggregationIndex} in definition ${definitionIndex} with type ${type} requires childField`,
);
@@ -132,7 +153,11 @@ const collectValuesByKey = (
}
Object.entries(value).forEach(([currentKey, entryValue]) => {
if (currentKey === key && typeof entryValue === 'string' && entryValue.trim().length > 0) {
if (
currentKey === key &&
typeof entryValue === 'string' &&
entryValue.trim().length > 0
) {
result.add(entryValue);
return;
}
@@ -160,11 +185,17 @@ export const resolveRollupConfig = (): RollupConfig => {
try {
const parsed = JSON.parse(override) as unknown;
validateRollupConfig(parsed);
return parsed;
return parsed as RollupConfig;
} catch (error) {
const reason =
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error';
throw new Error(`Unable to parse rollup configuration override (reason: ${reason})`);
error instanceof Error
? error.message
: typeof error === 'string'
? error
: 'Unknown error';
throw new Error(
`Unable to parse rollup configuration override (reason: ${reason})`,
);
}
}
@@ -2,7 +2,8 @@ import { computeAggregations } from './aggregations';
import { buildChildRecordIndex, TwentyClient } from './client';
import { extractRelationValues, resolveRollupConfig } from './config';
import { getNestedValue } from './filtering';
import type { ExecutionSummaryItem, RollupDefinition } from './types';
import type { ExecutionSummaryItem } from './types';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
@@ -60,17 +61,26 @@ const getApiCredentials = () => {
return { apiKey, baseUrl };
};
const formatSummary = (summaries: ExecutionSummaryItem[], durationMs: number) => ({
const formatSummary = (
summaries: ExecutionSummaryItem[],
durationMs: number,
) => ({
status: 'ok',
tookMs: durationMs,
totals: {
processed: summaries.reduce((accumulator, item) => accumulator + item.processed, 0),
updated: summaries.reduce((accumulator, item) => accumulator + item.updated, 0),
processed: summaries.reduce(
(accumulator, item) => accumulator + item.processed,
0,
),
updated: summaries.reduce(
(accumulator, item) => accumulator + item.updated,
0,
),
},
details: summaries,
});
export async function main(params: unknown): Promise<object> {
export const main = async (params: unknown): Promise<any> => {
const start = Date.now();
try {
const config = resolveRollupConfig();
@@ -92,7 +102,9 @@ export async function main(params: unknown): Promise<object> {
const credentials = getApiCredentials();
if (!credentials) {
console.warn('[rollup] skipping execution because TWENTY_API_KEY is not set');
console.warn(
'[rollup] skipping execution because TWENTY_API_KEY is not set',
);
return { status: 'noop', reason: 'TWENTY_API_KEY not configured' };
}
@@ -102,7 +114,9 @@ export async function main(params: unknown): Promise<object> {
const summaries: ExecutionSummaryItem[] = [];
for (const definition of config) {
const targetIds = fullRebuild ? undefined : relationCache.get(definition.relationField);
const targetIds = fullRebuild
? undefined
: relationCache.get(definition.relationField);
if (!fullRebuild && (!targetIds || targetIds.size === 0)) {
summaries.push({
@@ -116,7 +130,11 @@ export async function main(params: unknown): Promise<object> {
continue;
}
const childIndex = await buildChildRecordIndex(definition, client, targetIds);
const childIndex = await buildChildRecordIndex(
definition,
client,
targetIds,
);
const updates: Array<{
id: string;
@@ -138,13 +156,21 @@ export async function main(params: unknown): Promise<object> {
console.info(
`[rollup] computed aggregates for ${definition.parentObject} ${parentId}: ${JSON.stringify(payload)}`,
);
updates.push({ id: parentId, payload, context: { relationId: parentId } });
updates.push({
id: parentId,
payload,
context: { relationId: parentId },
});
});
let updatedCount = 0;
for (const update of updates) {
try {
await client.updateObject(definition.parentObject, update.id, update.payload);
await client.updateObject(
definition.parentObject,
update.id,
update.payload,
);
updatedCount += 1;
console.info(
`[rollup] updated ${definition.parentObject} ${update.id} (relation ${update.context.relationId})`,
@@ -204,4 +230,28 @@ export async function main(params: unknown): Promise<object> {
: 'Unknown error',
};
}
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'c3ec36c8-5b1d-421f-9172-a9e035ab9c18',
name: 'calculaterollups',
triggers: [
{
universalIdentifier: 'eec8aaf2-b0cc-47fd-b522-8d4aa5fe4bd3',
type: 'databaseEvent',
eventName: 'opportunity.*',
},
{
universalIdentifier: 'a3fea230-1121-44a6-b395-5811c3031f8e',
type: 'cron',
pattern: ' 0 2 * * *',
},
{
universalIdentifier: 'd33b0fe4-4b2b-45c0-aa2f-e617fdbba484',
type: 'route',
path: '/recalculate-all',
httpMethod: 'POST',
isAuthRequired: true,
},
],
};
@@ -0,0 +1,27 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -239,15 +239,6 @@ __metadata:
languageName: node
linkType: hard
"Rollup engine@workspace:.":
version: 0.0.0-use.local
resolution: "Rollup engine@workspace:."
dependencies:
dotenv: "npm:^16.4.5"
tsx: "npm:^4.20.6"
languageName: unknown
linkType: soft
"abbrev@npm:^3.0.0":
version: 3.0.1
resolution: "abbrev@npm:3.0.1"
@@ -912,6 +903,15 @@ __metadata:
languageName: node
linkType: hard
"rollup-engine@workspace:.":
version: 0.0.0-use.local
resolution: "rollup-engine@workspace:."
dependencies:
dotenv: "npm:^16.4.5"
tsx: "npm:^4.20.6"
languageName: unknown
linkType: soft
"safer-buffer@npm:>= 2.1.2 < 3.0.0":
version: 2.1.2
resolution: "safer-buffer@npm:2.1.2"
@@ -0,0 +1,29 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '0ed2bcb8-64ab-4ca1-b875-eeabf41b5f95',
displayName: 'Stripe synchronizer',
description: 'Plugin synchronizing data from Stripe to Twenty',
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',
},
},
};
export default config;
@@ -1,4 +1,5 @@
{
"name": "stripe-synchronizer",
"version": "0.0.1",
"license": "MIT",
"engines": {
@@ -7,27 +8,6 @@
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"universalIdentifier": "0ed2bcb8-64ab-4ca1-b875-eeabf41b5f95",
"name": "stripe-synchronizer",
"description": "Plugin synchronizing data from Stripe to Twenty",
"env": {
"TWENTY_API_KEY": {
"isSecret": true,
"value": "",
"description": "Required to send requests to Twenty"
},
"TWENTY_API_URL": {
"isSecret": false,
"value": "",
"description": "Optional, defaults to cloud API URL"
},
"STRIPE_API_KEY": {
"isSecret": true,
"value": "",
"description": "Required to send request to Stripe"
}
},
"dependencies": {
"axios": "^1.13.1"
}
@@ -1,14 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json",
"universalIdentifier": "cd15a738-18a5-406e-8b83-959dc52ebe14",
"name": "stripe",
"triggers": [
{
"universalIdentifier": "55f58e19-d832-43c4-9f8b-3f29fc05c162",
"type": "route",
"path": "/stripe",
"httpMethod": "POST",
"isAuthRequired": false
}
]
}
@@ -1,4 +1,5 @@
import axios from 'axios';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
const TWENTY_API_URL: string =
@@ -21,14 +22,14 @@ enum stripeStatus {
type stripeData = {
quantity: number;
}
};
type stripeItems = {
data: stripeData[];
};
type stripeResponse = {
customer: string,
customer: string;
items: stripeItems;
status: stripeStatus;
type: string;
@@ -36,15 +37,15 @@ type stripeResponse = {
type stripeCustomer = {
businessName: string;
}
};
type twentyObject = {
id: string;
nameSingular: string;
fields: Record<string, any>[];
}
};
const getCompaniesObject = async (): Promise<twentyObject> => {
const getCompaniesObject = async (): Promise<twentyObject | undefined> => {
const options = {
method: 'GET',
headers: {
@@ -58,7 +59,7 @@ const getCompaniesObject = async (): Promise<twentyObject> => {
const companyObject = response.data.data.objects.find(
(object: twentyObject) => object.nameSingular === 'company',
);
return companyObject as twentyObject ?? {} as twentyObject;
return (companyObject as twentyObject) ?? ({} as twentyObject);
}
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -129,8 +130,8 @@ const createFields = async (objectId: string, fieldName: string) => {
label: 'Paused',
value: stripeStatus.Paused,
position: 8,
}
]
},
],
};
const options = {
@@ -152,7 +153,9 @@ const createFields = async (objectId: string, fieldName: string) => {
}
};
const getStripeCustomerData = async (customerID: string): Promise<stripeCustomer> => {
const getStripeCustomerData = async (
customerID: string,
): Promise<stripeCustomer | undefined> => {
const options = {
method: 'GET',
url: `${STRIPE_API_URL}/${customerID}`,
@@ -163,7 +166,9 @@ const getStripeCustomerData = async (customerID: string): Promise<stripeCustomer
};
try {
const response = await axios(options);
return response.status === 200 ? response.data as stripeCustomer : {} as stripeCustomer ;
return response.status === 200
? (response.data as stripeCustomer)
: ({} as stripeCustomer);
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
@@ -171,7 +176,10 @@ const getStripeCustomerData = async (customerID: string): Promise<stripeCustomer
}
};
const checkIfCompanyExistsInTwenty = async (name: string) => {
const checkIfCompanyExistsInTwenty = async (name: string | undefined) => {
if (!name) {
return {};
}
const options = {
method: 'GET',
headers: {
@@ -195,7 +203,7 @@ const updateTwentyCompany = async (
companyId: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean> => {
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
@@ -219,7 +227,7 @@ const updateTwentyCompany = async (
};
const createTwentyCustomer = async (
customerName: string,
customerName: string | undefined,
seats: number,
subStatus: string,
) => {
@@ -248,13 +256,14 @@ const createTwentyCustomer = async (
export const main = async (params: {
properties: unknown;
}): Promise<object> => {
}): Promise<object | undefined> => {
if (TWENTY_API_KEY === '' || STRIPE_API_KEY === '') {
console.warn('Missing variables');
return {};
}
try { // TODO: add validation of signature key from Stripe
try {
// TODO: add validation of signature key from Stripe
const { properties } = params;
const stripe = properties as stripeResponse;
const allowed_types = [
@@ -268,38 +277,41 @@ export const main = async (params: {
const companyObject = await getCompaniesObject();
if (
companyObject.fields.find((field) => field.name === 'seats') === undefined
companyObject?.fields.find((field) => field.name === 'seats') ===
undefined
) {
const t: boolean = await createFields(companyObject.id, 'seats');
if (t == false) {
const t: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'seats')
: false;
if (t === false) {
console.error('Seats field creation failed');
return {};
}
}
if (
companyObject.fields.find((field) => field.name === 'subStatus') ===
companyObject?.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const t: boolean = await createFields(companyObject.id, 'subStatus');
if (t == false) {
const t: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'subStatus')
: false;
if (t === false) {
console.error('Sub status field creation failed');
return {};
}
}
const stripeCustomer: stripeCustomer = await getStripeCustomerData(
stripe.customer,
);
if (stripeCustomer.businessName) {
const stripeCustomer = await getStripeCustomerData(stripe.customer);
if (stripeCustomer?.businessName) {
console.warn('Set customer business name in Stripe');
return {};
}
const twentyCustomer = await checkIfCompanyExistsInTwenty(
stripeCustomer.businessName,
stripeCustomer?.businessName,
);
if (Object.keys(twentyCustomer).length === 0) {
const a = await createTwentyCustomer(
stripeCustomer.businessName,
stripeCustomer?.businessName,
stripe.items.data[0].quantity,
stripe.status.toUpperCase(),
);
@@ -326,3 +338,17 @@ export const main = async (params: {
return {};
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'cd15a738-18a5-406e-8b83-959dc52ebe14',
name: 'stripe',
triggers: [
{
universalIdentifier: '55f58e19-d832-43c4-9f8b-3f29fc05c162',
type: 'route',
path: '/stripe',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};
@@ -0,0 +1,27 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '8f3c5a1e-9b2d-4c7e-a5f3-1d8e9c2b4a6f',
displayName: 'Webmetic Visitor Intelligence',
description:
'Automatically sync B2B website visitor data into Twenty CRM. Identify companies visiting your website and track engagement without forms or manual entry.',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'eb3866a1-36df-42f3-bcb8-351120b74096',
description: 'Twenty API key for authentication',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: '10dfe92a-b472-43e6-a33a-db89dcdd9bad',
description: 'Twenty API URL (base URL of your Twenty instance)',
isSecret: false,
value: '',
},
WEBMETIC_API_KEY: {
universalIdentifier: '28540b41-ab79-489b-953f-c1491adc28f2',
description: 'Webmetic API key (get from hub.webmetic.de)',
isSecret: true,
},
WEBMETIC_DOMAIN: {
universalIdentifier: '74f7d252-539a-4eba-8d12-e7010c8128a1',
description: 'Your website domain to track (e.g., example.com)',
isSecret: false,
},
},
};
export default config;
@@ -1,5 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"name": "webmetic-visitor-intelligence",
"version": "0.0.1",
"license": "MIT",
"engines": {
@@ -8,28 +8,6 @@
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"universalIdentifier": "8f3c5a1e-9b2d-4c7e-a5f3-1d8e9c2b4a6f",
"name": "Webmetic Visitor Intelligence",
"description": "Automatically sync B2B website visitor data into Twenty CRM. Identify companies visiting your website and track engagement without forms or manual entry.",
"env": {
"TWENTY_API_KEY": {
"description": "Twenty API key for authentication",
"isSecret": true
},
"TWENTY_API_URL": {
"description": "Twenty API URL (base URL of your Twenty instance)",
"isSecret": false,
"value": ""
},
"WEBMETIC_API_KEY": {
"description": "Webmetic API key (get from hub.webmetic.de)",
"isSecret": true
},
"WEBMETIC_DOMAIN": {
"description": "Your website domain to track (e.g., example.com)",
"isSecret": false
}
},
"dependencies": {
"axios": "^1.12.2",
"twenty-sdk": "0.0.3"
@@ -1,12 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json",
"universalIdentifier": "f2d4b8e6-3a1c-4f7e-9b5d-8c3e2a1f6d4b",
"name": "sync-visitor-data",
"triggers": [
{
"universalIdentifier": "c5e9a3b7-2d8f-4c1e-a6b3-9f2e5d8c1a7b",
"type": "cron",
"schedule": "0 * * * *"
}
]
}
@@ -1,9 +1,13 @@
import axios from 'axios';
import { WebmeticResponse, WebmeticCompany } from './types';
import { ensureWebsiteLeadObjectExists, ensureWebsiteLeadFieldsExist } from './schemaSetup';
import { type WebmeticResponse } from './types';
import {
ensureWebsiteLeadObjectExists,
ensureWebsiteLeadFieldsExist,
} from './schemaSetup';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
// Rate limiting helper: delay between API calls to avoid hitting limits
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const RATE_LIMIT_DELAY = 800;
export const main = async (): Promise<object> => {
@@ -15,16 +19,23 @@ export const main = async (): Promise<object> => {
};
// Debug: Log all available environment variables
log('Available environment variables: ' + Object.keys(process.env).filter(k => k.includes('TWENTY') || k.includes('WEBMETIC')).join(', '));
log(
'Available environment variables: ' +
Object.keys(process.env)
.filter((k) => k.includes('TWENTY') || k.includes('WEBMETIC'))
.join(', '),
);
const WEBMETIC_API_KEY = process.env.WEBMETIC_API_KEY;
const WEBMETIC_DOMAIN = process.env.WEBMETIC_DOMAIN;
const TWENTY_API_KEY = process.env.TWENTY_API_KEY;
// Base URL without /rest for metadata operations
const TWENTY_API_BASE_URL = process.env.TWENTY_API_URL !== "" && process.env.TWENTY_API_URL !== undefined
? process.env.TWENTY_API_URL
: "http://localhost:3000";
const TWENTY_API_BASE_URL =
process.env.TWENTY_API_URL !== '' &&
process.env.TWENTY_API_URL !== undefined
? process.env.TWENTY_API_URL
: 'http://localhost:3000';
// REST URL for data operations
const TWENTY_API_URL = `${TWENTY_API_BASE_URL}/rest`;
@@ -42,7 +53,7 @@ export const main = async (): Promise<object> => {
WEBMETIC_API_KEY: !!WEBMETIC_API_KEY,
WEBMETIC_DOMAIN: !!WEBMETIC_DOMAIN,
TWENTY_API_KEY: !!TWENTY_API_KEY,
detailedLog: logs.join('\n')
detailedLog: logs.join('\n'),
};
}
@@ -60,7 +71,7 @@ export const main = async (): Promise<object> => {
return {
success: false,
error: `Field setup failed: ${error.message}`,
detailedLog: logs.join('\n')
detailedLog: logs.join('\n'),
};
}
@@ -86,11 +97,13 @@ export const main = async (): Promise<object> => {
Authorization: WEBMETIC_API_KEY,
},
params: webmeticParams,
}
},
);
log(`✅ Webmetic API responded successfully`);
log(`📦 Webmetic returned ${webmeticResponse.data.result?.length || 0} companies`);
log(
`📦 Webmetic returned ${webmeticResponse.data.result?.length || 0} companies`,
);
const companies = webmeticResponse.data.result;
@@ -101,12 +114,17 @@ export const main = async (): Promise<object> => {
companiesProcessed: 0,
sessionsProcessed: 0,
logCount: logs.length,
detailedLog: logs.join('\n')
detailedLog: logs.join('\n'),
};
}
const totalSessions = companies.reduce((sum, c) => sum + c.sessions.length, 0);
log(`\n📊 Found ${companies.length} companies with ${totalSessions} total sessions`);
const totalSessions = companies.reduce(
(sum, c) => sum + c.sessions.length,
0,
);
log(
`\n📊 Found ${companies.length} companies with ${totalSessions} total sessions`,
);
log('='.repeat(50));
// 2. Process each company
@@ -122,10 +140,14 @@ export const main = async (): Promise<object> => {
for (const company of companies) {
try {
log(`\n🏢 Processing: ${company.company_name} (${company.sessions.length} sessions)`);
log(
`\n🏢 Processing: ${company.company_name} (${company.sessions.length} sessions)`,
);
// Extract domain from company_url
const domainMatch = company.company_url?.match(/^https?:\/\/(?:www\.)?([^\/]+)/);
const domainMatch = company.company_url?.match(
/^https?:\/\/(?:www\.)?([^/]+)/,
);
const domain = domainMatch ? domainMatch[1] : company.company_url;
log(` Domain: ${domain}`);
@@ -148,12 +170,17 @@ export const main = async (): Promise<object> => {
const existingCompaniesResponse = await axios(existingCompaniesOptions);
log(` ✅ Response status: ${existingCompaniesResponse.status}`);
log(` 📦 Response data structure: ${JSON.stringify(Object.keys(existingCompaniesResponse.data || {}))}`);
log(
` 📦 Response data structure: ${JSON.stringify(Object.keys(existingCompaniesResponse.data || {}))}`,
);
await sleep(RATE_LIMIT_DELAY); // Rate limiting delay
const existingCompanies = existingCompaniesResponse.data?.data?.companies || [];
log(` Found ${existingCompanies.length} existing companies with this domain`);
const existingCompanies =
existingCompaniesResponse.data?.data?.companies || [];
log(
` Found ${existingCompanies.length} existing companies with this domain`,
);
// Build company data with proper validation
const companyData: any = {
@@ -165,7 +192,12 @@ export const main = async (): Promise<object> => {
};
// Only add address if at least one field has a value
if (company.address || company.city || company.postal_code || company.country) {
if (
company.address ||
company.city ||
company.postal_code ||
company.country
) {
companyData.address = {
addressStreet1: company.address || '',
addressCity: company.city || '',
@@ -250,7 +282,9 @@ export const main = async (): Promise<object> => {
companyId = newCompanyResponse.data?.data?.createCompany?.id;
if (!companyId) {
log(` ⚠️ Warning: Could not extract company ID from response. Full response: ${JSON.stringify(newCompanyResponse.data)}`);
log(
` ⚠️ Warning: Could not extract company ID from response. Full response: ${JSON.stringify(newCompanyResponse.data)}`,
);
}
results.companiesCreated++;
@@ -274,7 +308,9 @@ export const main = async (): Promise<object> => {
: 'Direct';
// Extract pages visited (limit length to prevent DB errors)
const pagesArray = session.user_data.map((ud) => ud.document_location);
const pagesArray = session.user_data.map(
(ud) => ud.document_location,
);
let pagesVisited = pagesArray.join(' → ');
// Truncate if too long (max ~1000 chars to be safe)
@@ -289,13 +325,19 @@ export const main = async (): Promise<object> => {
const scrollDepths = session.user_data
.map((ud) => ud.scroll_depth)
.filter((sd) => typeof sd === 'number' && !isNaN(sd));
const averageScrollDepth = scrollDepths.length > 0
? Math.round(scrollDepths.reduce((sum, sd) => sum + sd, 0) / scrollDepths.length)
: 0;
const averageScrollDepth =
scrollDepths.length > 0
? Math.round(
scrollDepths.reduce((sum, sd) => sum + sd, 0) /
scrollDepths.length,
)
: 0;
// Calculate total user events
const totalUserEvents = session.user_data
.reduce((sum, ud) => sum + (ud.user_events_count || 0), 0);
const totalUserEvents = session.user_data.reduce(
(sum, ud) => sum + (ud.user_events_count || 0),
0,
);
// Use session_id as unique identifier (company name available via relation)
const leadName = session.session_id;
@@ -314,7 +356,9 @@ export const main = async (): Promise<object> => {
const existingLeadsResponse = await axios(existingLeadsOptions);
await sleep(RATE_LIMIT_DELAY); // Rate limiting delay
const existingLeads = existingLeadsResponse.data?.data?.websiteLeads || existingLeadsResponse.data;
const existingLeads =
existingLeadsResponse.data?.data?.websiteLeads ||
existingLeadsResponse.data;
log(` Found ${existingLeads?.length || 0} existing leads`);
if (!existingLeads || existingLeads.length === 0) {
@@ -364,7 +408,9 @@ export const main = async (): Promise<object> => {
leadData.companyId = companyId;
}
log(` Creating WebsiteLead with data: ${JSON.stringify(leadData)}`);
log(
` Creating WebsiteLead with data: ${JSON.stringify(leadData)}`,
);
const createOptions = {
method: 'POST',
@@ -378,7 +424,9 @@ export const main = async (): Promise<object> => {
const createResponse = await axios(createOptions);
await sleep(RATE_LIMIT_DELAY); // Rate limiting delay
log(` WebsiteLead creation response: ${createResponse.status}`);
log(
` WebsiteLead creation response: ${createResponse.status}`,
);
results.websiteLeadsCreated++;
log(` ✓ Created WebsiteLead: ${leadName}`);
@@ -386,7 +434,10 @@ export const main = async (): Promise<object> => {
log(` - WebsiteLead already exists: ${leadName}`);
}
} catch (sessionError: any) {
const errorDetails = sessionError?.response?.data || sessionError?.message || 'Unknown error';
const errorDetails =
sessionError?.response?.data ||
sessionError?.message ||
'Unknown error';
const errorMsg = `Error processing WebsiteLead for session ${session.session_id}: ${JSON.stringify(errorDetails, null, 2)}`;
console.error(errorMsg);
log(`${errorMsg}`);
@@ -395,9 +446,9 @@ export const main = async (): Promise<object> => {
results.companiesProcessed++;
results.sessionsProcessed += company.sessions.length;
} catch (error: any) {
const errorDetails = error?.response?.data || error?.message || 'Unknown error';
const errorDetails =
error?.response?.data || error?.message || 'Unknown error';
const errorMsg = `Error processing company ${company.company_name}: ${
error instanceof Error ? error.message : 'Unknown error'
}`;
@@ -409,13 +460,21 @@ export const main = async (): Promise<object> => {
// Log additional diagnostic info
if (error?.response) {
log(` 🔍 HTTP Status: ${error.response.status} ${error.response.statusText}`);
log(
` 🔍 HTTP Status: ${error.response.status} ${error.response.statusText}`,
);
log(` 🔍 Request URL: ${error.config?.url || 'Unknown'}`);
log(` 🔍 Request Method: ${error.config?.method?.toUpperCase() || 'Unknown'}`);
log(` 🔍 Auth Header: ${error.config?.headers?.Authorization ? 'Present' : 'MISSING!'}`);
log(
` 🔍 Request Method: ${error.config?.method?.toUpperCase() || 'Unknown'}`,
);
log(
` 🔍 Auth Header: ${error.config?.headers?.Authorization ? 'Present' : 'MISSING!'}`,
);
}
results.errors.push(`${errorMsg} - Details: ${JSON.stringify(errorDetails)}`);
results.errors.push(
`${errorMsg} - Details: ${JSON.stringify(errorDetails)}`,
);
}
}
@@ -441,11 +500,22 @@ export const main = async (): Promise<object> => {
return {
...results,
logCount: logs.length,
detailedLog: logs.join('\n')
detailedLog: logs.join('\n'),
};
} catch (error) {
console.error('Fatal error during sync:', error);
throw error;
}
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'f2d4b8e6-3a1c-4f7e-9b5d-8c3e2a1f6d4b',
name: 'sync-visitor-data',
triggers: [
{
universalIdentifier: 'c5e9a3b7-2d8f-4c1e-a6b3-9f2e5d8c1a7b',
type: 'cron',
pattern: '0 * * * *',
},
],
};
@@ -6,6 +6,7 @@
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
@@ -262,9 +262,9 @@ __metadata:
languageName: node
linkType: hard
"webmetic@workspace:.":
"webmetic-visitor-intelligence@workspace:.":
version: 0.0.0-use.local
resolution: "webmetic@workspace:."
resolution: "webmetic-visitor-intelligence@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
axios: "npm:^1.12.2"
@@ -6,6 +6,7 @@
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,