Compare commits

..
4 Commits
Author SHA1 Message Date
guillim 7174f211d9 fix 2025-11-04 17:18:39 +01:00
guillim c9d0df6a62 1.8 -> 1.10 fix 2025-11-04 17:08:03 +01:00
guillim 0dfa4032fc adding to 1.10 2025-11-04 17:02:22 +01:00
guillim 8d1ab3e7c5 release 1.10 flush cache command 2025-11-04 16:59:30 +01:00
6478 changed files with 110939 additions and 467091 deletions
+1 -2
View File
@@ -12,7 +12,6 @@ This directory contains Twenty's development guidelines and best practices in th
### Core Guidelines
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
- **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files)
### Code Quality
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
@@ -41,7 +40,7 @@ You can manually reference any rule using the `@ruleName` syntax:
- `@testing-guidelines` - Get testing recommendations
### Rule Types Used
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
- **Auto Attached** - Loaded when matching file patterns are referenced
- **Agent Requested** - Available for AI to include when relevant
- **Manual** - Only included when explicitly mentioned
-324
View File
@@ -1,324 +0,0 @@
---
description: Process and guidelines for creating release changelogs for Twenty CRM
globs: ["**/releases/*.mdx", "**/releases/**"]
alwaysApply: false
---
# Twenty Release Changelog Process
Complete guide for creating release changelogs, including codebase research, file structure, and content guidelines.
## Prerequisites
Before starting, gather the following information:
### Required Information
**Version Number**: `{VERSION}` (e.g., 1.9.0, 2.0.0, 2.1.0)
**Release Date**: Use today's date in format: YYYY-MM-DD
### Changes/Features to Document
List the features and changes to include in this release:
1. **Feature Name**: ______________________________
- Brief description: ______________________________
- Related area (workflow, UI, backend, etc.): ______________________________
2. **Feature Name**: ______________________________
- Brief description: ______________________________
- Related area: ______________________________
3. **Feature Name**: ______________________________
- Brief description: ______________________________
- Related area: ______________________________
## Codebase Research Guide
If feature descriptions are not provided or need enhancement, research the codebase:
### Where to Look
**For Workflow Features:**
- Frontend: `packages/twenty-front/src/modules/workflow/`
- Backend: `packages/twenty-server/src/modules/workflow/`
- Components: `packages/twenty-front/src/modules/workflow/components/`
**For UI/UX Changes:**
- Components: `packages/twenty-front/src/modules/ui/`
- Layout: `packages/twenty-front/src/modules/layout/`
- Design system: `packages/twenty-ui/src/`
**For Backend/API Features:**
- Server modules: `packages/twenty-server/src/modules/`
- Entities: `packages/twenty-server/src/entities/`
- Services: Look for `*.service.ts` files
**For Database/ORM Changes:**
- Migrations: `packages/twenty-server/src/database/typeorm/`
- Entities: `packages/twenty-server/src/entities/`
### Research Commands
```bash
# Find recent merged PRs (adjust date as needed)
gh pr list --search "merged:>2025-10-01" --limit 50 --state merged
# View recent commits
git log --since="2 weeks ago" --oneline --no-merges
# View commits between releases (replace with actual release tags)
git log v1.7.0..v1.8.0 --oneline
# Search for specific feature keywords in code
grep -r "iterator" packages/twenty-front/src/modules/workflow/
grep -r "bulk select" packages/twenty-front/src/modules/workflow/
# Find recent changes in specific directory
git log --since="2 weeks ago" --oneline -- packages/twenty-front/src/modules/workflow/
```
### Using Codebase Search
Use the AI codebase search to find:
- "How does the workflow iterator node work?"
- "Where is bulk select implemented for workflows?"
- "What changes were made to the search node limit?"
## Step-by-Step Process
### 1. Setup Git Branch
**IMPORTANT**: Always start from an up-to-date main branch to avoid merge conflicts and ensure the changelog is based on the latest code.
```bash
cd /Users/thomascolasdesfrancs/code/twenty
git checkout main
git pull origin main
git checkout -b {VERSION}
```
Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
⚠️ **Do this first** before making any file changes. This ensures your branch is based on the latest main.
### 2. Create File Structure
**Create changelog file:**
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
**Create image folder:**
- Path: `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website/public/images/releases/2.0/`
```bash
# Create the image folder
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
### 3. Move Illustration Files
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
**Naming Convention:** `{VERSION}-descriptive-name.png`
Examples:
- `1.9.0-feature-name.png`
- `1.9.0-another-feature.png`
```bash
# Move and rename files
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
```
### 4. Research Features (if needed)
If descriptions are not provided:
1. Use the research commands above to find recent PRs and commits
2. Search the codebase for feature-related code
3. Read PR descriptions for context
4. Check component comments and documentation
### 5. Write Changelog Content
Create the MDX file with this structure:
```markdown
---
release: {VERSION}
Date: {YYYY-MM-DD}
---
# Feature 1 Name
Short description explaining what the feature does and why it's useful. Keep it user-focused and concise (1-2 sentences).
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.png)
# Feature 2 Name
Another short description of the second feature.
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.png)
# Feature 3 Name
Description of the third feature.
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-3.png)
```
**Style Guidelines:**
- Use H1 (`#`) for feature names
- Keep descriptions to 1-2 sentences
- Focus on user benefits, not technical implementation
- Use active voice
- Start with what the user can now do
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
**Reference Previous Changelogs:**
- Check `packages/twenty-website/src/content/releases/` for examples
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
### 6. Review
Open the changelog file for review:
```bash
# Open in Cursor
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
# Open image folder to verify illustrations
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
Review checklist:
- [ ] Version number is correct in frontmatter
- [ ] Date is today's date
- [ ] All features are documented
- [ ] Image paths are correct
- [ ] Image files exist in the folder
- [ ] Descriptions are clear and user-focused
- [ ] Spelling and grammar are correct
### 7. Present Changelog for User Approval
**IMPORTANT**: Before committing and creating the PR, always show the complete changelog content to the user and wait for explicit approval.
**What to show:**
1. Display the full MDX content of the changelog file
2. Confirm that illustration files were moved to the correct location
3. List the image file names and paths
**What to say:**
```
I've created the changelog for version {VERSION}. Here's the content for your review:
[Show full MDX content]
Images moved to:
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.png
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.png
Please review the content. Once you approve, I'll commit the changes and create the pull request.
```
**Wait for user approval before proceeding to step 8.**
Possible user responses:
- "Looks good" / "Approve" / "Create the PR" → Proceed to step 8
- Requests changes → Make the requested edits, then show content again
- Asks questions → Answer them, then wait for approval
### 8. Commit Changes
```bash
# Check status
git status
# Add files
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
# Commit
git commit -m "Add {VERSION} release changelog"
# Push branch
git push -u origin {VERSION}
```
### 9. Create Pull Request
```bash
# Create PR using GitHub CLI
gh pr create \
--title "Release {VERSION}" \
--body "## Release {VERSION}
This release includes:
- Feature 1
- Feature 2
- Feature 3
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
Release date: {DATE}" \
--base main \
--head {VERSION}
```
Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
## File Naming Conventions
### Changelog Files
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
- **Convention**: One file per complete version
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
- **Location**: `packages/twenty-website/src/content/releases/`
### Image Folders
- **Format**: `{MAJOR}.{MINOR}/`
- **Convention**: One folder per minor version (shared across patches)
- **Examples**: `1.6/`, `1.7/`, `2.0/`
- **Location**: `packages/twenty-website/public/images/releases/`
### Image Files
- **Format**: `{VERSION}-descriptive-name.png`
- **Convention**: Kebab-case descriptive names
- **Examples**:
- `1.8.0-workflow-iterator.png`
- `1.8.0-bulk-select.png`
- `1.9.0-new-feature.png`
## Quick Reference Template
Copy and fill this for each release:
```
VERSION: ___________
DATE: ___________
MINOR_VERSION: ___________
Features to document:
1. ___________________________
2. ___________________________
3. ___________________________
Branch name: {VERSION}
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
```
## Tips
- **Start early**: Begin documenting features as they're developed
- **User perspective**: Write for users, not developers
- **Be concise**: 1-2 sentences per feature
- **Visual first**: Illustrations should showcase the feature clearly
- **Consistent style**: Match tone and structure of previous changelogs
- **Test links**: Verify all image paths work before committing
- **Research thoroughly**: Use codebase search to understand features deeply
+3 -58
View File
@@ -30,18 +30,6 @@ type ButtonProps = {}; // Component props suffix with 'Props'
// ✅ Files and directories - kebab-case
// user-profile.component.tsx
// user-profile.styles.ts
// ❌ NEVER use abbreviations in variable names
// Bad
const users = data.map((u) => u.name);
const field = items.find((f) => f.id === id);
// Good
const users = data.map((user) => user.name);
const field = items.find((item) => item.id === id);
const fieldMetadata = inlineFields.find(
(fieldMetadataItem) => fieldMetadataItem.name === fieldName,
);
```
## Import Organization
@@ -71,11 +59,11 @@ const processUserData = (
): ProcessedUser => {
const processedUser = transformUserData(user);
applyOptions(processedUser, options);
if (callback) {
callback(processedUser);
}
return processedUser;
};
```
@@ -83,7 +71,7 @@ const processUserData = (
## Comments
```typescript
// ✅ Use short-form comments, NOT JSDoc blocks
// ✅ Explain business logic and non-obvious intentions (WHY, not WHAT)
// ✅ Explain business logic and non-obvious intentions
// Apply 15% discount for premium users with orders > $100
const discount = isPremiumUser && orderTotal > 100 ? 0.15 : 0;
@@ -97,55 +85,12 @@ const calculateTotalPrice = (basePrice: number): number => {
// Implementation
};
// ❌ AVOID obvious comments that just describe what code does
// Bad: Get all inline fields dynamically
const { inlineFieldMetadataItems } = useFieldListFieldMetadataItems({...});
// Bad: Define standard fields in display order
const standardFieldOrder = ['startsAt', 'endsAt', 'conferenceLink'];
// Bad: Split fields into standard and custom
const standardFields = standardFieldOrder.map(...)
// ✅ GOOD: Only comment if explaining non-obvious business logic
// Calendar events display standard fields first, then custom fields after participants
// to maintain consistency with the legacy UI behavior
const standardFields = standardFieldOrder.map(...)
// ❌ AVOID JSDoc blocks - use short comments instead
/**
* This style is NOT preferred in this codebase
*/
```
**Comment Guidelines:**
- **DO** comment complex business rules or domain-specific logic
- **DO** comment non-obvious algorithmic decisions
- **DO** add TODOs for future improvements
- **DON'T** comment obvious variable declarations or function calls
- **DON'T** comment what is already clear from well-named variables/functions
- **DON'T** add comments that just repeat what the code says
## Utility Helpers
```typescript
// ✅ Use existing utility helpers instead of manual checks
import { isDefined } from 'twenty-shared/utils';
import { isNonEmptyString, isNonEmptyArray } from '@sniptt/guards';
// ❌ Manual type guards
const validItems = items.filter((item): item is Item => item !== undefined);
const hasValue = value !== null && value !== undefined;
// ✅ Use utility helpers
const validItems = items.filter(isDefined);
const hasValue = isDefined(value);
// Other useful helpers:
// - isDefined(value) - checks !== null && !== undefined
// - isNonEmptyString(value) - checks string is defined and not empty
// - isNonEmptyArray(value) - checks array is defined and has items
```
## Security Patterns
```typescript
// ✅ CSV Export: Always apply security first, then formatting
-29
View File
@@ -1,29 +0,0 @@
---
description: Guidelines for generating and managing TypeORM migrations in twenty-server
globs: [
"packages/twenty-server/src/**/*.entity.ts",
"packages/twenty-server/src/database/typeorm/**/*.ts"
]
alwaysApply: false
---
## Server Migrations (twenty-server)
- **When changing an entity, always generate a migration**
- If you modify a `*.entity.ts` file in `packages/twenty-server/src`, you **must** generate a corresponding TypeORM migration instead of manually editing the database schema.
- Use the Nx + TypeORM command from the project root:
```bash
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
```
- Replace `[name]` with a descriptive, kebab-case migration name that reflects the change (for example, `add-agent-turn-evaluation`).
- **Prefer generated migrations over manual edits**
- Let TypeORM infer schema changes from the updated entities; only adjust the generated migration file manually if absolutely necessary (for example, for data backfills or complex constraints).
- Keep schema changes (DDL) in these generated migrations and avoid mixing in heavy data migrations unless there is a strong reason and clear comments.
- **Keep migrations consistent and reversible**
- Ensure the generated migration includes both `up` and `down` logic that correctly applies and reverts the entity change when possible.
- Do not delete or rewrite existing, committed migrations unless you are explicitly working on a pre-release branch where history rewrites are allowed by team conventions.
-8
View File
@@ -1,8 +0,0 @@
{
"setup-worktree": [
"nvm use",
"yarn",
"cp $ROOT_WORKTREE_PATH/packages/twenty-server/.env packages/twenty-server/.env || true",
"cp $ROOT_WORKTREE_PATH/packages/twenty-front/.env packages/twenty-front/.env || true"
]
}
+1 -1
View File
@@ -63,4 +63,4 @@ git push origin your-branch-name
## Reporting Issues
If you face any issues or have suggestions, please feel free to [create an issue on Twenty's GitHub repository](https://github.com/twentyhq/twenty/issues/new). Please provide as much detail as possible.
If you face any issues or have suggestions, please feel free to (create an issue on Twenty's GitHub repository)[https://github.com/twentyhq/twenty/issues/new]. Please provide as much detail as possible.
-15
View File
@@ -1,15 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
versioning-strategy: "lockfile-only"
assignees:
- "mabdullahabaid"
ignore:
- dependency-name: "@graphql-yoga/nestjs"
- dependency-name: "@nestjs/graphql"
- dependency-name: "@ptc-org/nestjs-query-graphql"
- dependency-name: "typeorm"
+103
View File
@@ -0,0 +1,103 @@
name: CI CLI
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-cli/**
packages/twenty-server/**
cli-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test, build]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:cli
tasks: ${{ matrix.task }}
cli-e2e-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, cli-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
NODE_ENV: test
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Server / Append billing config to .env.test
working-directory: packages/twenty-server
run: |
echo "" >> .env.test
echo "IS_BILLING_ENABLED=true" >> .env.test
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: CLI / Run E2E Tests
run: npx nx test:e2e twenty-cli
ci-cli-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, cli-test, cli-e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-55
View File
@@ -1,55 +0,0 @@
name: CI Create App
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
create-app-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test, build]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:create-app
tasks: ${{ matrix.task }}
ci-create-app-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-47
View File
@@ -1,47 +0,0 @@
name: CI Docs
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-docs/**
eslint.config.mjs
docs-lint:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Docs / Lint English MDX files
run: npx eslint "packages/twenty-docs/{developers,user-guide,twenty-ui,getting-started,snippets}/**/*.mdx" --max-warnings 0
-102
View File
@@ -1,102 +0,0 @@
name: CI SDK
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-sdk/**
sdk-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test, build]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:sdk
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
NODE_ENV: test
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Server / Append billing config to .env.test
working-directory: packages/twenty-server
run: |
echo "" >> .env.test
echo "IS_BILLING_ENABLED=true" >> .env.test
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: SDK / Run E2E Tests
run: npx nx test:e2e twenty-sdk
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test, sdk-e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+3 -3
View File
@@ -126,13 +126,13 @@ jobs:
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
# Check if GraphQL generated files were modified
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata; then
# Check if any files were modified
if ! git diff --quiet; then
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
echo "The following GraphQL schema changes were detected:"
echo "==================================================="
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata
git diff
echo "==================================================="
echo ""
echo "Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
@@ -1,8 +1,4 @@
name: 'Test Docker Compose'
permissions:
contents: read
on:
pull_request:
-157
View File
@@ -1,157 +0,0 @@
name: 'Pull docs translations from Crowdin'
permissions:
contents: write
pull-requests: write
on:
schedule:
- cron: '0 */2 * * *' # Every two hours
workflow_dispatch:
inputs:
force_pull:
description: 'Force pull translations regardless of status'
required: false
type: boolean
default: false
workflow_call:
inputs:
force_pull:
description: 'Force pull translations regardless of status'
required: false
type: boolean
default: false
pull_request:
paths:
- 'packages/twenty-docs/**'
- 'crowdin.yml'
- '.github/workflows/docs-i18n-pull.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
pull_docs_translations:
name: Pull docs translations
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Setup i18n branch
if: github.event_name != 'pull_request'
run: |
git fetch origin i18n || true
git checkout -B i18n origin/i18n || git checkout -b i18n
- name: Configure git
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
- name: Stash any changes before pulling translations
if: github.event_name != 'pull_request'
run: |
git add .
git stash || true
- name: Pull translated docs from Crowdin
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
source: 'packages/twenty-docs/**/*.mdx'
translation: 'packages/twenty-docs/l/%two_letters_code%/**/%original_file_name%'
export_only_approved: false
localization_branch_name: i18n
base_url: 'https://twenty.api.crowdin.com'
skip_untranslated_files: true
push_translations: false
create_pull_request: false
skip_ref_checkout: true
dryrun_action: false
env:
GITHUB_TOKEN: ${{ github.token }}
CROWDIN_PROJECT_ID: '1'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Fix file permissions
if: github.event_name != 'pull_request'
run: sudo chown -R runner:docker . || true
- name: Fix translated documentation links
run: bash packages/twenty-docs/scripts/fix-translated-links.sh
- name: Regenerate navigation template
if: github.event_name == 'pull_request'
run: yarn docs:generate-navigation-template
- name: Regenerate docs.json
run: yarn docs:generate
- name: Commit artifacts to pull request branch
if: github.event_name == 'pull_request'
run: |
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json
if git diff --staged --quiet --exit-code; then
echo "No navigation/doc changes to commit."
exit 0
fi
git commit -m "chore: sync docs artifacts"
git push origin "HEAD:$HEAD_REF"
env:
HEAD_REF: ${{ github.head_ref }}
- name: Check for changes and commit
if: github.event_name != 'pull_request'
id: check_changes
run: |
git add .
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: update docs translations from Crowdin and fix internal links"
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Push changes
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
run: git push origin HEAD:i18n
- name: Create pull request
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
run: |
if git diff --name-only origin/main..HEAD | grep -q .; then
gh pr create -B main -H i18n --title 'i18n - docs translations' --body 'Created by Github action' || true
else
echo "No file differences between branches, skipping PR creation"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-68
View File
@@ -1,68 +0,0 @@
name: 'Push docs to Crowdin'
permissions:
contents: write
on:
workflow_dispatch:
workflow_call:
push:
branches: ['main', 'docs-localized-navigation']
paths:
- 'packages/twenty-docs/**/*.mdx'
- '!packages/twenty-docs/fr/**'
- 'crowdin.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
push_docs:
name: Push documentation to Crowdin
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Generate navigation template for Crowdin
run: yarn docs:generate-navigation-template
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Generate navigation template for Crowdin
run: yarn docs:generate-navigation-template
- name: Upload docs to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
localization_branch_name: i18n
base_url: 'https://twenty.api.crowdin.com'
env:
CROWDIN_PROJECT_ID: 1
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+2 -4
View File
@@ -74,8 +74,6 @@ jobs:
upload_sources: false
upload_translations: false
download_translations: true
source: '**/en.po'
translation: '%original_path%/%locale%.po'
export_only_approved: false
localization_branch_name: i18n
base_url: 'https://twenty.api.crowdin.com'
@@ -93,7 +91,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
CROWDIN_PROJECT_ID: '1'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# As the files are extracted from a Docker container, they belong to root:root
# We need to fix this before the next steps
- name: Fix file permissions
@@ -105,7 +103,7 @@ jobs:
# if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
run: |
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
git status
git config --global user.name 'github-actions'
+1 -1
View File
@@ -65,7 +65,7 @@ npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run migrations
# Generate migration
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/[name] -d src/database/typeorm/core/core.datasource.ts
# Sync metadata
npx nx run twenty-server:command workspace:sync-metadata
+1 -33
View File
@@ -27,37 +27,5 @@ files: [
# e.g. "/resources/%two_letters_code%/%original_file_name%"
#
"translation": "%original_path%/%locale%.po",
},
{
#
# MDX documentation files - user-guide
# Using md type to preserve JSX component structure
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
#
"source": "packages/twenty-docs/user-guide/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
},
{
#
# MDX documentation files - developers
# Using md type to preserve JSX component structure
#
"source": "packages/twenty-docs/developers/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
},
{
#
# MDX documentation files - twenty-ui
# Using md type to preserve JSX component structure
#
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
},
{
#
# Navigation labels template - translated into per-locale navigation.json
#
"source": "packages/twenty-docs/navigation/navigation.template.json",
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
}
]
]
+1 -24
View File
@@ -4,7 +4,6 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import linguiPlugin from 'eslint-plugin-lingui';
import * as mdxPlugin from 'eslint-plugin-mdx';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import unicornPlugin from 'eslint-plugin-unicorn';
@@ -61,7 +60,7 @@ export default [
},
{
sourceTag: 'scope:sdk',
onlyDependOnLibsWithTags: ['scope:sdk', 'scope:shared'],
onlyDependOnLibsWithTags: ['scope:sdk'],
},
{
sourceTag: 'scope:shared',
@@ -192,26 +191,4 @@ export default [
parser: jsoncParser,
},
},
// MDX files
{
...mdxPlugin.flat,
plugins: {
...mdxPlugin.flat.plugins,
'@nx': nxPlugin,
},
},
mdxPlugin.flatCodeBlocks,
{
files: ['**/*.mdx'],
rules: {
'no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'off',
'unused-imports/no-unused-vars': 'off',
// Enforce JSX tags on separate lines to prevent Crowdin translation issues
'@nx/workspace-mdx-component-newlines': 'error',
// Disallow angle bracket placeholders to prevent Crowdin translation errors
'@nx/workspace-no-angle-bracket-placeholders': 'error',
},
},
];
+4 -4
View File
@@ -36,7 +36,7 @@
"dependsOn": ["^build"]
},
"start": {
"cache": false,
"cache": true,
"dependsOn": ["^build"]
},
"lint": {
@@ -133,7 +133,7 @@
"outputs": ["{projectRoot}/{options.output-dir}"],
"options": {
"cwd": "{projectRoot}",
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build --test",
"output-dir": "storybook-static",
"config-dir": ".storybook"
},
@@ -234,7 +234,7 @@
"command": "nx storybook:build {projectName}",
"forwardAllArgs": false
},
"chromatic --storybook-build-dir=storybook-static {args.ci}"
"cross-var chromatic --project-token=$CHROMATIC_PROJECT_TOKEN --storybook-build-dir=storybook-static {args.ci}"
],
"parallel": false
},
@@ -280,7 +280,7 @@
}
},
"installation": {
"version": "22.0.3"
"version": "21.3.11"
},
"generators": {
"@nx/react": {
+19 -14
View File
@@ -9,6 +9,8 @@
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
"@radix-ui/colors": "^3.0.0",
"@sentry/profiling-node": "^9.26.0",
"@sentry/react": "^9.26.0",
"@sniptt/guards": "^0.2.0",
"@tabler/icons-react": "^3.31.0",
"@wyw-in-js/vite": "^0.7.0",
@@ -73,14 +75,15 @@
"@graphql-codegen/typescript": "^3.0.4",
"@graphql-codegen/typescript-operations": "^3.0.4",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@nx/eslint": "22.0.3",
"@nx/eslint-plugin": "22.0.3",
"@nx/jest": "22.0.3",
"@nx/js": "22.0.3",
"@nx/react": "22.0.3",
"@nx/storybook": "22.0.3",
"@nx/vite": "22.0.3",
"@nx/web": "22.0.3",
"@nx/eslint": "21.3.11",
"@nx/eslint-plugin": "21.3.11",
"@nx/jest": "21.3.11",
"@nx/js": "21.3.11",
"@nx/react": "21.3.11",
"@nx/storybook": "21.3.11",
"@nx/vite": "21.3.11",
"@nx/web": "21.3.11",
"@playwright/test": "^1.46.0",
"@sentry/types": "^8",
"@storybook/addon-actions": "8.6.14",
"@storybook/addon-coverage": "^1.0.0",
@@ -146,6 +149,7 @@
"@yarnpkg/types": "^4.0.0",
"chromatic": "^6.18.0",
"concurrently": "^8.2.2",
"cross-var": "^1.1.0",
"danger": "^13.0.4",
"dotenv-cli": "^7.4.4",
"esbuild": "^0.25.10",
@@ -154,7 +158,6 @@
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-lingui": "^0.9.0",
"eslint-plugin-mdx": "^3.6.2",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-project-structure": "^3.9.1",
@@ -173,7 +176,8 @@
"jsdom": "~22.1.0",
"msw": "^2.0.11",
"msw-storybook-addon": "^2.0.5",
"nx": "22.0.3",
"nx": "21.3.11",
"playwright": "^1.46.0",
"prettier": "^3.1.1",
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
@@ -187,7 +191,11 @@
"ts-node": "10.9.1",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"vite": "^7.0.0"
"vite": "^7.0.0",
"vite-plugin-checker": "^0.10.2",
"vite-plugin-cjs-interop": "^2.2.0",
"vite-plugin-dts": "3.8.1",
"vite-plugin-svgr": "^4.2.0"
},
"engines": {
"node": "^24.5.0",
@@ -208,8 +216,6 @@
"version": "0.2.1",
"nx": {},
"scripts": {
"docs:generate": "tsx packages/twenty-docs/scripts/generate-docs-json.ts",
"docs:generate-navigation-template": "tsx packages/twenty-docs/scripts/generate-navigation-template.ts",
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
},
"workspaces": {
@@ -227,7 +233,6 @@
"packages/twenty-sdk",
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"tools/eslint-rules"
]
}
-97
View File
@@ -1,97 +0,0 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg" height="128">
</picture>
</a>
<h1>Create Twenty App</h1>
<a href="https://www.npmjs.com/package/create-twenty-app"><img alt="NPM version" src="https://img.shields.io/npm/v/create-twenty-app.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://github.com/twentyhq/twenty/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/npm/l/next.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://discord.gg/cx5n4Jzs57"><img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-blueviolet.svg?style=for-the-badge&logo=Twenty&labelColor=000000&logoWidth=20"></a>
</div>
Create Twenty App is the official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). It sets up a readytorun project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
- Zeroconfig project bootstrap
- Preconfigured scripts for auth, generate, dev sync, oneoff sync, uninstall
- Strong TypeScript support and typed client generation
## Prerequisites
- Node.js 24+ (recommended) and Yarn 4
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
## Quick start
```bash
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Authenticate using your API key (you'll be prompted)
yarn auth
# Add a new entity to your application (guided)
yarn create-entity
# Generate a typed Twenty client and workspace entity types
yarn generate
# Start dev mode: automatically syncs local changes to your workspace
yarn dev
# Or run a onetime sync
yarn sync
# Watch your application's functions logs
yarn logs
# Uninstall the application from the current workspace
yarn uninstall
# Display commands' help
yarn help
```
## What gets scaffolded
- A minimal app structure ready for Twenty
- TypeScript configuration
- Prewired scripts that wrap the `twenty` CLI from twenty-sdk
- Example placeholders to help you add entities, actions, and sync logic
## Next steps
- Explore the generated project and add your first entity with `yarn create-entity`.
- Keep your types uptodate using `yarn generate`.
- Use `yarn dev` while you iterate to see changes instantly in your workspace.
## Publish your application
Applications are currently stored in `twenty/packages/twenty-apps`.
You can share your application with all Twenty users:
```bash
# pull the Twenty project
git clone https://github.com/twentyhq/twenty.git
cd twenty
# create a new branch
git checkout -b feature/my-awesome-app
```
- Copy your app folder into `twenty/packages/twenty-apps`.
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
```bash
git commit -m "Add new application"
git push
```
Our team reviews contributions for quality, security, and reusability before merging.
## Troubleshooting
- Auth prompts not appearing: run `yarn auth` again and verify the API key permissions.
- Types not generated: ensure `yarn generate` runs without errors, then restart `yarn dev`.
## Contributing
- See our [GitHub](https://github.com/twentyhq/twenty)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
-54
View File
@@ -1,54 +0,0 @@
{
"name": "create-twenty-app",
"version": "0.1.3",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
"files": [
"dist/**/*"
],
"scripts": {
"build": "npx rimraf dist && npx vite build"
},
"keywords": [
"twenty",
"cli",
"crm",
"application",
"development"
],
"exports": {
".": {
"types": "./dist/cli.d.ts",
"import": "./dist/cli.mjs",
"require": "./dist/cli.cjs"
}
},
"license": "AGPL-3.0",
"dependencies": {
"@genql/cli": "^3.0.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"fs-extra": "^11.2.0",
"inquirer": "^10.0.0",
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}
-56
View File
@@ -1,56 +0,0 @@
{
"name": "create-twenty-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"tags": ["scope:create-app"],
"targets": {
"build": {
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"]
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/create-twenty-app",
"command": "tsx src/cli.ts"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/create-twenty-app",
"command": "node dist/cli.cjs"
}
},
"typecheck": {},
"lint": {
"options": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"configurations": {
"ci": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"fix": {}
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs"
},
"configurations": {
"ci": {
"ci": true,
"coverage": true,
"watchAll": false
}
}
}
}
}
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import { CreateAppCommand } from '@/create-app.command';
import packageJson from '../package.json';
const program = new Command(packageJson.name)
.description('CLI tool to initialize a new Twenty application')
.version(
packageJson.version,
'-v, --version',
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.helpOption('-h, --help', 'Display this help message.')
.action(async (directory?: string) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
console.error(
chalk.red(
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
),
);
process.exit(1);
}
await new CreateAppCommand().execute(directory);
});
program.exitOverride();
try {
program.parse();
} catch (error) {
if (error instanceof CommanderError) {
process.exit(error.exitCode);
}
if (error instanceof Error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
}
@@ -1 +0,0 @@
24.5.0
@@ -1,27 +0,0 @@
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
First, authenticate to your workspace:
```bash
yarn auth
```
Then, install this app to your workspace:
```bash
yarn sync
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -1,137 +0,0 @@
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
export default [
// Base JS rules
js.configs.recommended,
// Global ignores
{
ignores: ['**/node_modules/**', '**/dist/**', '**/coverage/**'],
},
// Base config for TS/JS files
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: {
prettier: prettierPlugin,
import: importPlugin,
'prefer-arrow': preferArrowPlugin,
'unused-imports': unusedImportsPlugin,
},
rules: {
// General rules (aligned with main project)
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': [
'warn',
{ allow: ['group', 'groupCollapsed', 'groupEnd'] },
],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
// Import rules
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
// Prefer arrow functions
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
// Unused imports
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
// Prettier (formatting as lint errors if you want)
'prettier/prettier': 'error',
},
},
// TypeScript-specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
// Turn off base rule and use TS-aware versions
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
// Test files (Jest)
{
files: ['**/*.spec.@(ts|tsx|js|jsx)', '**/*.test.@(ts|tsx|js|jsx)'],
languageOptions: {
globals: {
jest: true,
describe: true,
it: true,
expect: true,
beforeEach: true,
afterEach: true,
beforeAll: true,
afterAll: true,
},
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
];
@@ -1,26 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -1,128 +0,0 @@
import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import * as path from 'path';
import { copyBaseApplicationProject } from '@/utils/app-template';
import kebabCase from 'lodash.kebabcase';
import { convertToLabel } from '@/utils/convert-to-label';
import { tryGitInit } from '@/utils/try-git-init';
import { install } from '@/utils/install';
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
export class CreateAppCommand {
async execute(directory?: string): Promise<void> {
try {
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(directory);
await this.validateDirectory(appDirectory);
this.logCreationInfo({ appDirectory, appName });
await fs.ensureDir(appDirectory);
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
});
await install(appDirectory);
await tryGitInit(appDirectory);
this.logSuccess(appDirectory);
} catch (error) {
console.error(
chalk.red('Initialization failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async getAppInfos(directory?: string): Promise<{
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}> {
const { name, displayName, description } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Application name:',
when: () => !directory,
default: 'my-awesome-app',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
return true;
},
},
{
type: 'input',
name: 'displayName',
message: 'Application display name:',
default: (answers: any) => {
return convertToLabel(answers?.name ?? directory);
},
},
{
type: 'input',
name: 'description',
message: 'Application description (optional):',
default: '',
},
]);
const computedName = name ?? directory;
const appName = computedName.trim();
const appDisplayName = displayName.trim();
const appDescription = description.trim();
const appDirectory = directory
? path.join(CURRENT_EXECUTION_DIRECTORY, directory)
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
return { appName, appDisplayName, appDirectory, appDescription };
}
private async validateDirectory(appDirectory: string): Promise<void> {
if (!(await fs.pathExists(appDirectory))) {
return;
}
const files = await fs.readdir(appDirectory);
if (files.length > 0) {
throw new Error(
`Directory ${appDirectory} already exists and is not empty`,
);
}
}
private logCreationInfo({
appDirectory,
appName,
}: {
appDirectory: string;
appName: string;
}): void {
console.log(chalk.blue('🎯 Creating Twenty Application'));
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
console.log(chalk.gray(`📝 Name: ${appName}`));
console.log('');
}
private logSuccess(appDirectory: string): void {
console.log(chalk.green('✅ Application created!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(`cd ${appDirectory.split('/').reverse()[0] ?? ''}`);
console.log('yarn auth');
}
}
@@ -1,10 +0,0 @@
import { convertToLabel } from '@/utils/convert-to-label';
describe('convertToLabel', () => {
it('should convert to label', () => {
expect(convertToLabel('toto')).toBe('Toto');
expect(convertToLabel('totoTata')).toBe('Toto tata');
expect(convertToLabel('totoTataTiti')).toBe('Toto tata titi');
expect(convertToLabel('toto-tata-titi')).toBe('Toto tata titi');
});
});
@@ -1,143 +0,0 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { v4 } from 'uuid';
export const copyBaseApplicationProject = async ({
appName,
appDisplayName,
appDescription,
appDirectory,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}) => {
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
await createPackageJson({ appName, appDirectory });
await createGitignore(appDirectory);
await createYarnLock(appDirectory);
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
appDirectory,
});
};
const createYarnLock = async (appDirectory: string) => {
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
`;
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
};
const createGitignore = async (appDirectory: string) => {
const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
`;
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
};
const createApplicationConfig = async ({
displayName,
description,
appDirectory,
}: {
displayName: string;
description?: string;
appDirectory: string;
}) => {
const content = `import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
};
export default config;
`;
await fs.writeFile(join(appDirectory, 'application.config.ts'), content);
};
const createPackageJson = async ({
appName,
appDirectory,
}: {
appName: string;
appDirectory: string;
}) => {
const packageJson = {
name: appName,
version: '0.1.0',
license: 'MIT',
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
scripts: {
'create-entity': 'twenty app add',
dev: 'twenty app dev',
generate: 'twenty app generate',
sync: 'twenty app sync',
logs: 'twenty app logs',
uninstall: 'twenty app uninstall',
help: 'twenty help',
auth: 'twenty auth login',
},
dependencies: {
'twenty-sdk': '0.1.3',
},
devDependencies: {
'@types/node': '^24.7.2',
typescript: '^5.9.3',
},
};
await fs.writeFile(
join(appDirectory, 'package.json'),
JSON.stringify(packageJson, null, 2),
'utf8',
);
};
@@ -1,6 +0,0 @@
import { startCase } from 'lodash';
export const convertToLabel = (str: string) => {
const s = startCase(str).toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
};
@@ -1,13 +0,0 @@
import chalk from 'chalk';
import { promisify } from 'util';
import { exec } from 'child_process';
const execPromise = promisify(exec);
export const install = async (root: string) => {
try {
await execPromise('yarn', { cwd: root });
} catch (error: any) {
console.error(chalk.red('yarn install failed:'), error.stdout);
}
};
@@ -1,70 +0,0 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { promisify } from 'util';
import { exec } from 'child_process';
const execPromise = promisify(exec);
const isInGitRepository = async (root: string): Promise<boolean> => {
try {
await execPromise('git rev-parse --is-inside-work-tree', { cwd: root });
return true;
} catch {
// Empty catch block
}
return false;
};
const isInMercurialRepository = async (root: string): Promise<boolean> => {
try {
await execPromise('hg --cwd . root', { cwd: root });
return true;
} catch {
// Empty catch block
}
return false;
};
const isDefaultBranchSet = async (root: string): Promise<boolean> => {
try {
await execPromise('git config init.defaultBranch', { cwd: root });
return true;
} catch {
// Empty catch block
}
return false;
};
export const tryGitInit = async (root: string): Promise<boolean> => {
try {
await execPromise('git --version', { cwd: root });
if (
(await isInGitRepository(root)) ||
(await isInMercurialRepository(root))
) {
return false;
}
await execPromise('git init', { cwd: root });
try {
if (!(await isDefaultBranchSet(root))) {
await execPromise('git checkout -b main', { cwd: root });
}
await execPromise('git add -A', { cwd: root });
await execPromise(
'git commit -m "Initial commit from Create Twenty App"',
{
cwd: root,
},
);
return true;
} catch {
fs.rm(join(root, '.git'), { recursive: true, force: true });
return false;
}
} catch {
return false;
}
};
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
@@ -1,23 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**"
]
}
@@ -1,16 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": [
"**/__mocks__/**/*",
"vite.config.ts",
"jest.config.mjs",
"src/**/*.d.ts",
"src/**/*.spec.ts",
"src/**/*.spec.tsx",
"src/**/*.test.ts",
"src/**/*.test.tsx"
]
}
-96
View File
@@ -1,96 +0,0 @@
import fs from 'fs-extra';
import path from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import tsconfigPaths from 'vite-tsconfig-paths';
import packageJson from './package.json';
const moduleEntries = Object.keys((packageJson as any).exports || {})
.filter(
(key) => key !== './style.css' && key !== '.' && !key.startsWith('./src/'),
)
.map((module) => `src/${module.replace(/^\.\//, '')}/index.ts`);
const entries = ['src/cli.ts', ...moduleEntries];
const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
if (!chunk.isEntry) {
throw new Error(
`Should never occurs, encountered a non entry chunk ${chunk.facadeModuleId}`,
);
}
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
if (splitFaceModuleId === undefined) {
throw new Error(
`Should never occurs splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
);
}
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
if (moduleDirectory === 'src') {
return `${chunk.name}.${extension}`;
}
return `${moduleDirectory}.${extension}`;
};
const copyAssetPlugin = (targets: { src: string; dest: string }[]) => {
return {
name: 'copy-assets',
closeBundle: async () => {
for (const target of targets) {
await fs.copy(
path.resolve(__dirname, target.src),
path.resolve(__dirname, target.dest),
);
}
},
};
};
export default defineConfig(() => {
const tsConfigPath = path.resolve(__dirname, './tsconfig.lib.json');
return {
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/create-twenty-app',
plugins: [
tsconfigPaths({
root: __dirname,
}),
dts({ entryRoot: './src', tsconfigPath: tsConfigPath }),
copyAssetPlugin([
{
src: 'src/constants/base-application',
dest: 'dist/constants/base-application',
},
]),
],
build: {
outDir: 'dist',
lib: { entry: entries, name: 'create-twenty-app' },
rollupOptions: {
external: [
...Object.keys((packageJson as any).dependencies || {}),
'path',
'fs',
'child_process',
],
output: [
{
format: 'es',
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
},
{
format: 'cjs',
interop: 'auto',
esModule: true,
exports: 'named',
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
},
],
},
},
logLevel: 'warn',
};
});
-1
View File
@@ -1 +0,0 @@
generated
@@ -1,108 +0,0 @@
# Twenty CRM Activity Reporter
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!
## Features
- 🧑‍💻 **People & Company Tracking**: Summarizes newly created people and companies
- 🎯 **Opportunity Monitoring**: Reports on new opportunities created, broken down by stage
-**Task Analytics**:
- Tracks task creation
- Calculates on-time completion rates
- Identifies team members with the most overdue tasks (the "slackers")
- 🔔 **Multi-Platform Notifications**: Send reports to Slack, Discord, and/or WhatsApp
-**Configurable Time Range**: Look back any number of days
## Prerequisites
- Node.js (v14 or higher recommended)
- TypeScript
- A [Twenty CRM](https://twenty.com) account with API access
- Optional: Slack webhook, Discord webhook, and/or WhatsApp Business API access
## Installing dependencies
```bash
# Install dependencies
yarn install
```
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `TWENTY_API_KEY` | ✅ Yes | Your Twenty CRM API key |
| `DAYS_AGO` | ✅ Yes | Number of days to look back for the report |
| `SLACK_HOOK_URL` | ❌ No | Slack incoming webhook URL |
| `DISCORD_WEBHOOK_URL` | ❌ No | Discord webhook URL |
| `FB_GRAPH_TOKEN` | ❌ No | Facebook Graph API token for WhatsApp |
| `WHATSAPP_RECIPIENT_PHONE_NUMBER` | ❌ No | WhatsApp recipient phone number (with country code) |
## Project Structure
```
.
├── index.ts # Main entry point
├── people-creation-summariser.ts # Summarizes people/company creation
├── opportunity-creation-summariser.ts # Summarizes opportunity creation
├── task-creation-summariser.ts # Summarizes task creation & completion
├── senders.ts # Handles sending to Slack/Discord/WhatsApp
├── utils.ts # API request utility
└── README.md
```
## How It Works
1. **Data Collection**: The bot queries the Twenty CRM API for activities within the specified time range
2. **Analysis**:
- Counts new people and companies
- Categorizes opportunities by stage
- Calculates task completion rates and identifies overdue tasks
3. **Reporting**: Formats the data into friendly messages
4. **Distribution**: Sends reports to configured platforms (Slack, Discord, WhatsApp)
## Report Format
Each report includes:
```
Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last X days:
🧑‍💻 People & Companies
- X People and Y Companies were added
🎯 Opportunities
- X Opportunities were added: Y in NEW, Z in PROPOSAL
📋 Tasks
- X Tasks were created
- Y% Tasks were completed on time
- [Name] slacked the most with Z Tasks overdue
```
## API Integration
This bot uses the [Twenty CRM REST API](https://api.twenty.com/rest/). The following endpoints are used:
- `GET /people` - Fetch people data
- `GET /opportunities` - Fetch opportunity data
- `GET /tasks` - Fetch task data
- `GET /workspaceMembers/{id}` - Fetch workspace member details
## Notes
- The "slacker" detection is lighthearted and identifies team members with the most overdue tasks
- At least one messaging platform must be configured for the bot to send reports
- The bot uses ISO date format (YYYY-MM-DD) for date filtering
- Task completion percentage only considers incomplete tasks (excludes already completed tasks from the calculation)
## Troubleshooting
**Issue**: No messages being sent
- **Solution**: Ensure at least one messaging platform is configured with valid credentials
**Issue**: API authentication errors
- **Solution**: Verify your `TWENTY_API_KEY` is correct and has necessary permissions
**Issue**: WhatsApp messages not sending
- **Solution**: Ensure both `FB_GRAPH_TOKEN` and `WHATSAPP_RECIPIENT_PHONE_NUMBER` are set correctly
## Contributing
Built with ❤️ and 🥖 by Azmat, Ali and Mike from 9dots
@@ -1,45 +0,0 @@
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,17 +0,0 @@
{
"name": "activity-summary",
"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": {
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,80 +0,0 @@
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 body = {
daysAgo: Number(process.env.DAYS_AGO),
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
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) {
const discordBody = await sendToDiscord({
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
});
body = {
...body,
discord: discordBody,
};
}
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;
};
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,172 +0,0 @@
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 {
formattedMessage: slackMessage,
webhookStatus: response.status,
};
};
export const sendToDiscord = async (params: {
peopleCreationSummary: string;
opportunityCreationSummary: string;
taskCreationSummary: string;
}) => {
const {
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
} = params;
const formattedMessage = `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days:
**🧑‍💻 People & Companies**
${peopleCreationSummary}
**🎯 Opportunities**
${opportunityCreationSummary}
**📋 Tasks**
${taskCreationSummary}`;
const body = {
username: 'Twenty Bot',
content: formattedMessage,
};
const response = await fetch(process.env.DISCORD_WEBHOOK_URL ?? '', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return {
formattedMessage,
webhookStatus: response.status,
};
};
export const sendToWhatsApp = async (params: {
peopleCreationSummary: string;
opportunityCreationSummary: string;
taskCreationSummary: string;
}): Promise<object> => {
const {
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
} = params;
const formattedMessage = `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days:
*🧑‍💻 People & Companies*
${peopleCreationSummary}
*🎯 Opportunities*
${opportunityCreationSummary}
*📋 Tasks*
${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}`,
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: process.env.WHATSAPP_RECIPIENT_PHONE_NUMBER,
type: 'text',
text: {
preview_url: true,
body: formattedMessage,
},
}),
},
);
const responseBody = await response.json();
return {
formattedMessage,
webhookStatus: response.status,
webhookResponse: responseBody,
};
};
@@ -1,38 +0,0 @@
# 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.9.2
resolution: "@types/node@npm:24.9.2"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/7905d43f65cee72ef475fe76316e10bbf6ac5d08a7f0f6c38f2b6285d7ca3009e8fcafc8f8a1d2bf3f55889c9c278dbb203a9081fd0cf2d6d62161703924c6fa
languageName: node
linkType: hard
"activity-summary@workspace:.":
version: 0.0.0-use.local
resolution: "activity-summary@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
twenty-sdk: "npm:0.0.3"
languageName: unknown
linkType: soft
"twenty-sdk@npm:0.0.3":
version: 0.0.3
resolution: "twenty-sdk@npm:0.0.3"
checksum: 10c0/0a3c85c27edb22fb50f7eb0da4f9770e85729fce05e9e0118ad0cdfc36e42425c93340a6cd1c276daf30aeeaa612db0cd905831c0a8287a31bff3da5be9b0562
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
@@ -1,30 +0,0 @@
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,19 +0,0 @@
{
"name": "ai-meeting-transcript",
"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",
"openai": "^4.28.0",
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,306 +0,0 @@
import axios from 'axios';
import OpenAI from 'openai';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
type TranscriptWebhookPayload = {
transcript: string;
meetingTitle?: string;
meetingDate?: string;
participants?: string[];
metadata?: Record<string, unknown>;
};
type ActionItem = {
title: string;
description: string;
assignee?: string;
dueDate?: string;
};
type Commitment = {
person: string;
commitment: string;
dueDate?: string;
};
type AnalysisResult = {
summary: string;
keyPoints: string[];
actionItems: ActionItem[];
commitments: Commitment[];
};
type RichTextV2Data = {
markdown: string;
blocknote: null;
};
type TwentyApiResponse = {
id: string;
};
const OPENAI_MODEL = 'gpt-4o-mini';
const OPENAI_TEMPERATURE = 0.3;
const analyzeTranscript = async (
transcript: string,
openaiApiKey: string,
): Promise<AnalysisResult> => {
const openai = new OpenAI({ apiKey: openaiApiKey });
const prompt = `Analyze the following meeting transcript and extract:
1. A concise summary (2-3 sentences)
2. Key discussion points (bullet list)
3. Action items with titles, descriptions, and any mentioned assignees or due dates
4. Commitments made by participants with names and any mentioned due dates
Return the response as a JSON object with this structure:
{
"summary": "string",
"keyPoints": ["string"],
"actionItems": [{"title": "string", "description": "string", "assignee": "string (optional)", "dueDate": "string (optional)"}],
"commitments": [{"person": "string", "commitment": "string", "dueDate": "string (optional)"}]
}
Transcript:
${transcript}`;
const completion = await openai.chat.completions.create({
model: OPENAI_MODEL,
messages: [
{
role: 'system',
content:
'You are a meeting analysis assistant. Extract key insights, action items, and commitments from meeting transcripts. Always return valid JSON.',
},
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
temperature: OPENAI_TEMPERATURE,
});
const content = completion.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
return JSON.parse(content) as AnalysisResult;
};
const getTwentyApiConfig = () => {
const apiKey = process.env.TWENTY_API_KEY;
if (!apiKey) {
throw new Error('TWENTY_API_KEY environment variable is not set');
}
const baseUrl = process.env.TWENTY_API_URL;
return { apiKey, baseUrl };
};
const formatNoteBody = (summary: string, keyPoints: string[]): string => {
const keyPointsList = keyPoints.map((point) => `- ${point}`).join('\n');
return `## Summary\n\n${summary}\n\n## Key Points\n\n${keyPointsList}\n\n*Generated from meeting transcript*`;
};
const createNoteInTwenty = async (
summary: string,
keyPoints: string[],
meetingTitle?: string,
meetingDate?: string,
): Promise<TwentyApiResponse> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
const noteTitle =
meetingTitle ||
`Meeting Notes - ${meetingDate || new Date().toLocaleDateString()}`;
const noteBodyMarkdown = formatNoteBody(summary, keyPoints);
const requestData = {
title: noteTitle,
bodyV2: {
markdown: noteBodyMarkdown,
blocknote: null,
} satisfies RichTextV2Data,
};
try {
const { data } = await axios.post<TwentyApiResponse>(
`${baseUrl}/rest/notes`,
requestData,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
},
);
return 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;
throw new Error(
`Failed to create note: ${errorMessage}. Status: ${status}`,
);
}
throw error;
}
};
const createTaskInTwenty = async (
actionItem: ActionItem,
): Promise<TwentyApiResponse> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
const taskData: {
title: string;
bodyV2: RichTextV2Data;
dueAt?: string;
} = {
title: actionItem.title,
bodyV2: {
markdown: actionItem.description,
blocknote: null,
},
};
if (actionItem.dueDate) {
taskData.dueAt = new Date(actionItem.dueDate).toISOString();
}
try {
const { data } = await axios.post<TwentyApiResponse>(
`${baseUrl}/rest/tasks`,
taskData,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
},
);
return 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;
throw new Error(
`Failed to create task "${actionItem.title}": ${errorMessage}. Status: ${status}`,
);
}
throw error;
}
};
const createTasksFromActionItems = async (
actionItems: ActionItem[],
noteId: string,
): Promise<string[]> => {
const taskIds: string[] = [];
for (const actionItem of actionItems) {
try {
const taskDescription = `${actionItem.description}\n\n*Related to meeting note: ${noteId}*`;
const task = await createTaskInTwenty({
...actionItem,
description: taskDescription,
});
taskIds.push(task.id);
} catch (error) {
// Task creation failed, continue with next task
}
}
return taskIds;
};
const createTasksFromCommitments = async (
commitments: Commitment[],
noteId: string,
): Promise<string[]> => {
const taskIds: string[] = [];
for (const commitment of commitments) {
try {
const taskDescription = `Commitment from ${commitment.person}: ${commitment.commitment}\n\n*Related to meeting note: ${noteId}*`;
const task = await createTaskInTwenty({
title: `Follow up: ${commitment.commitment}`,
description: taskDescription,
dueDate: commitment.dueDate,
});
taskIds.push(task.id);
} catch (error) {
// Commitment task creation failed, continue with next commitment
}
}
return taskIds;
};
export const main = async (
params: TranscriptWebhookPayload,
): Promise<object> => {
const { transcript, meetingTitle, meetingDate } = params;
if (!transcript || typeof transcript !== 'string') {
throw new Error('Transcript is required and must be a string');
}
const openaiApiKey = process.env.OPENAI_API_KEY;
if (!openaiApiKey) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
const analysis = await analyzeTranscript(transcript, openaiApiKey);
const note = await createNoteInTwenty(
analysis.summary,
analysis.keyPoints,
meetingTitle,
meetingDate,
);
const actionItemTaskIds = await createTasksFromActionItems(
analysis.actionItems,
note.id,
);
const commitmentTaskIds = await createTasksFromCommitments(
analysis.commitments,
note.id,
);
const allTaskIds = [...actionItemTaskIds, ...commitmentTaskIds];
return {
success: true,
noteId: note.id,
taskIds: allTaskIds,
summary: {
noteCreated: true,
tasksCreated: allTaskIds.length,
actionItemsProcessed: analysis.actionItems.length,
commitmentsProcessed: analysis.commitments.length,
},
};
};
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,
},
],
};
@@ -1,435 +0,0 @@
# 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-fetch@npm:^2.6.4":
version: 2.6.13
resolution: "@types/node-fetch@npm:2.6.13"
dependencies:
"@types/node": "npm:*"
form-data: "npm:^4.0.4"
checksum: 10c0/6313c89f62c50bd0513a6839cdff0a06727ac5495ccbb2eeda51bb2bbbc4f3c0a76c0393a491b7610af703d3d2deb6cf60e37e59c81ceeca803ffde745dbf309
languageName: node
linkType: hard
"@types/node@npm:*, @types/node@npm:^24.7.2":
version: 24.9.2
resolution: "@types/node@npm:24.9.2"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/7905d43f65cee72ef475fe76316e10bbf6ac5d08a7f0f6c38f2b6285d7ca3009e8fcafc8f8a1d2bf3f55889c9c278dbb203a9081fd0cf2d6d62161703924c6fa
languageName: node
linkType: hard
"@types/node@npm:^18.11.18":
version: 18.19.130
resolution: "@types/node@npm:18.19.130"
dependencies:
undici-types: "npm:~5.26.4"
checksum: 10c0/22ba2bc9f8863101a7e90a56aaeba1eb3ebdc51e847cef4a6d188967ab1acbce9b4f92251372fd0329ecb924bbf610509e122c3dfe346c04dbad04013d4ad7d0
languageName: node
linkType: hard
"abort-controller@npm:^3.0.0":
version: 3.0.0
resolution: "abort-controller@npm:3.0.0"
dependencies:
event-target-shim: "npm:^5.0.0"
checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5
languageName: node
linkType: hard
"agentkeepalive@npm:^4.2.1":
version: 4.6.0
resolution: "agentkeepalive@npm:4.6.0"
dependencies:
humanize-ms: "npm:^1.2.1"
checksum: 10c0/235c182432f75046835b05f239708107138a40103deee23b6a08caee5136873709155753b394ec212e49e60e94a378189562cb01347765515cff61b692c69187
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"
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
languageName: node
linkType: hard
"async-generator-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-generator-function@npm:1.0.0"
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
languageName: node
linkType: hard
"asynckit@npm:^0.4.0":
version: 0.4.0
resolution: "asynckit@npm:0.4.0"
checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
languageName: node
linkType: hard
"axios@npm:^1.12.2":
version: 1.13.1
resolution: "axios@npm:1.13.1"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.4"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/de9c3c6de43d3ee1146d3afe78645f19450cac6a5d7235bef8b8e8eeb705c2e47e2d231dea99cecaec4dae1897c521118ca9413b9d474063c719c4d94c5b9adc
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
dependencies:
delayed-stream: "npm:~1.0.0"
checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
languageName: node
linkType: hard
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.3.0":
version: 1.3.0
resolution: "es-errors@npm:1.3.0"
checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85
languageName: node
linkType: hard
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.1.0":
version: 2.1.0
resolution: "es-set-tostringtag@npm:2.1.0"
dependencies:
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.6"
has-tostringtag: "npm:^1.0.2"
hasown: "npm:^2.0.2"
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
languageName: node
linkType: hard
"event-target-shim@npm:^5.0.0":
version: 5.0.1
resolution: "event-target-shim@npm:5.0.1"
checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b
languageName: node
linkType: hard
"follow-redirects@npm:^1.15.6":
version: 1.15.11
resolution: "follow-redirects@npm:1.15.11"
peerDependenciesMeta:
debug:
optional: true
checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
languageName: node
linkType: hard
"form-data-encoder@npm:1.7.2":
version: 1.7.2
resolution: "form-data-encoder@npm:1.7.2"
checksum: 10c0/56553768037b6d55d9de524f97fe70555f0e415e781cb56fc457a68263de3d40fadea2304d4beef2d40b1a851269bd7854e42c362107071892cb5238debe9464
languageName: node
linkType: hard
"form-data@npm:^4.0.4":
version: 4.0.4
resolution: "form-data@npm:4.0.4"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
languageName: node
linkType: hard
"formdata-node@npm:^4.3.2":
version: 4.4.1
resolution: "formdata-node@npm:4.4.1"
dependencies:
node-domexception: "npm:1.0.0"
web-streams-polyfill: "npm:4.0.0-beta.3"
checksum: 10c0/74151e7b228ffb33b565cec69182694ad07cc3fdd9126a8240468bb70a8ba66e97e097072b60bcb08729b24c7ce3fd3e0bd7f1f80df6f9f662b9656786e76f6a
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5
languageName: node
linkType: hard
"generator-function@npm:^2.0.0":
version: 2.0.1
resolution: "generator-function@npm:2.0.1"
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.6":
version: 1.3.1
resolution: "get-intrinsic@npm:1.3.1"
dependencies:
async-function: "npm:^1.0.0"
async-generator-function: "npm:^1.0.0"
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
generator-function: "npm:^2.0.0"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
languageName: node
linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.2":
version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2"
dependencies:
has-symbols: "npm:^1.0.3"
checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c
languageName: node
linkType: hard
"hasown@npm:^2.0.2":
version: 2.0.2
resolution: "hasown@npm:2.0.2"
dependencies:
function-bind: "npm:^1.1.2"
checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9
languageName: node
linkType: hard
"humanize-ms@npm:^1.2.1":
version: 1.2.1
resolution: "humanize-ms@npm:1.2.1"
dependencies:
ms: "npm:^2.0.0"
checksum: 10c0/f34a2c20161d02303c2807badec2f3b49cbfbbb409abd4f95a07377ae01cfe6b59e3d15ac609cffcd8f2521f0eb37b7e1091acf65da99aa2a4f1ad63c21e7e7a
languageName: node
linkType: hard
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
languageName: node
linkType: hard
"mime-db@npm:1.52.0":
version: 1.52.0
resolution: "mime-db@npm:1.52.0"
checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
languageName: node
linkType: hard
"mime-types@npm:^2.1.12":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
mime-db: "npm:1.52.0"
checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
languageName: node
linkType: hard
"ms@npm:^2.0.0":
version: 2.1.3
resolution: "ms@npm:2.1.3"
checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48
languageName: node
linkType: hard
"node-domexception@npm:1.0.0":
version: 1.0.0
resolution: "node-domexception@npm:1.0.0"
checksum: 10c0/5e5d63cda29856402df9472335af4bb13875e1927ad3be861dc5ebde38917aecbf9ae337923777af52a48c426b70148815e890a5d72760f1b4d758cc671b1a2b
languageName: node
linkType: hard
"node-fetch@npm:^2.6.7":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
dependencies:
whatwg-url: "npm:^5.0.0"
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8
languageName: node
linkType: hard
"openai@npm:^4.28.0":
version: 4.104.0
resolution: "openai@npm:4.104.0"
dependencies:
"@types/node": "npm:^18.11.18"
"@types/node-fetch": "npm:^2.6.4"
abort-controller: "npm:^3.0.0"
agentkeepalive: "npm:^4.2.1"
form-data-encoder: "npm:1.7.2"
formdata-node: "npm:^4.3.2"
node-fetch: "npm:^2.6.7"
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
bin:
openai: bin/cli
checksum: 10c0/c4f2e837684ed96b8cec58c65a584646d667c69918f29052775e2e8c05ff5c860d8b58214a7770bc6895ca8602480420c1db6a5392dd250179eb0b91c2b19a2f
languageName: node
linkType: hard
"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
languageName: node
linkType: hard
"tr46@npm:~0.0.3":
version: 0.0.3
resolution: "tr46@npm:0.0.3"
checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11
languageName: node
linkType: hard
"twenty-sdk@npm:^0.0.2":
version: 0.0.2
resolution: "twenty-sdk@npm:0.0.2"
checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e
languageName: node
linkType: hard
"undici-types@npm:~5.26.4":
version: 5.26.5
resolution: "undici-types@npm:5.26.5"
checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
"web-streams-polyfill@npm:4.0.0-beta.3":
version: 4.0.0-beta.3
resolution: "web-streams-polyfill@npm:4.0.0-beta.3"
checksum: 10c0/a9596779db2766990117ed3a158e0b0e9f69b887a6d6ba0779940259e95f99dc3922e534acc3e5a117b5f5905300f527d6fbf8a9f0957faf1d8e585ce3452e8e
languageName: node
linkType: hard
"webidl-conversions@npm:^3.0.0":
version: 3.0.1
resolution: "webidl-conversions@npm:3.0.1"
checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db
languageName: node
linkType: hard
"whatwg-url@npm:^5.0.0":
version: 5.0.0
resolution: "whatwg-url@npm:5.0.0"
dependencies:
tr46: "npm:~0.0.3"
webidl-conversions: "npm:^3.0.0"
checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5
languageName: node
linkType: hard
@@ -1,68 +0,0 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
displayName: 'Fireflies',
description: 'Sync Fireflies meeting summaries, sentiment, and action items into Twenty.',
icon: 'IconMicrophone',
applicationVariables: {
FIREFLIES_WEBHOOK_SECRET: {
universalIdentifier: 'f51f7646-be9f-4ba9-9b75-160dd288cd0c',
description: 'Secret key for verifying Fireflies webhook signatures',
isSecret: true,
},
FIREFLIES_API_KEY: {
universalIdentifier: 'faa41f07-b28e-4500-b1c0-ce4b3d27924c',
description: 'Fireflies GraphQL API key used to fetch meeting summaries',
isSecret: true,
},
TWENTY_API_KEY: {
universalIdentifier: '02756551-5bf7-4fb2-8e08-1f622008d305',
description: 'Twenty API key used when running scripts locally',
isSecret: true,
},
SERVER_URL: {
universalIdentifier: '9b3a5e8e-5973-4e6b-a059-2966075652aa',
description: 'Base URL for the Twenty workspace (default: http://localhost:3000)',
value: 'http://localhost:3000',
},
AUTO_CREATE_CONTACTS: {
universalIdentifier: 'c4fa946e-e06b-4d54-afb6-288b0ac75bdf',
description: 'Whether to auto-create contacts for unknown participants',
value: 'true',
},
LOG_LEVEL: {
universalIdentifier: '2b019cf1-d198-48dd-943e-110571aa541e',
description: 'Log level: silent, error, warn, info, debug (default: error)',
value: 'error',
},
FIREFLIES_SUMMARY_STRATEGY: {
universalIdentifier: '562b43d9-cd47-4ec1-ae16-5cc7ebc9729b',
description: 'Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only',
value: 'immediate_with_retry',
},
FIREFLIES_RETRY_ATTEMPTS: {
universalIdentifier: '670ca203-01ce-4ae8-8294-eb38b29434f2',
description: 'Number of retry attempts when fetching summaries',
value: '3',
},
FIREFLIES_RETRY_DELAY: {
universalIdentifier: '2e8ccb82-9390-47ba-b628-ca2726931bce',
description: 'Delay in milliseconds between retry attempts',
value: '5000',
},
FIREFLIES_POLL_INTERVAL: {
universalIdentifier: '904538f7-7bec-4ee6-9bac-5d43c619b667',
description: 'Polling interval (ms) when using delayed polling strategy',
value: '30000',
},
FIREFLIES_MAX_POLLS: {
universalIdentifier: '84d54c97-5572-4c01-9039-764ab3aa87b8',
description: 'Maximum number of polling attempts when waiting for summaries',
value: '10',
},
},
};
export default config;
@@ -1,29 +0,0 @@
{
"name": "fireflies",
"version": "0.2.2",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"test": "jest",
"setup:fields": "tsx scripts/add-meeting-fields.ts",
"test:webhook": "tsx scripts/test-webhook.ts"
},
"dependencies": {
"axios": "^1.13.1",
"dotenv": "^16.3.1",
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/jest": "^29.5.5",
"@types/node": "^24.9.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"tsx": "^4.19.3",
"typescript": "^5.9.3"
}
}
@@ -1,95 +0,0 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
export interface LoggerConfig {
logLevel: LogLevel;
isTestEnvironment: boolean;
}
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
silent: 4,
};
/**
* App-level Fireflies application logger with configurable log levels.
*/
export class AppLogger {
private config: LoggerConfig;
private context: string;
constructor(context: string) {
this.context = context;
this.config = {
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
};
}
private parseLogLevel(level: string): LogLevel {
const normalizedLevel = level.toLowerCase() as LogLevel;
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
}
/**
* Log debug information (LOG_LEVEL=debug)
*/
debug(message: string, ...args: any[]): void {
if (this.shouldLog('debug')) {
// eslint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
/**
* Log informational messages (LOG_LEVEL=info or lower)
*/
info(message: string, ...args: any[]): void {
if (this.shouldLog('info')) {
// eslint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
/**
* Log warnings (LOG_LEVEL=warn or lower)
*/
warn(message: string, ...args: any[]): void {
if (this.shouldLog('warn')) {
// eslint-disable-next-line no-console
console.warn(`[${this.context}] ${message}`, ...args);
}
}
/**
* Log errors (LOG_LEVEL=error or lower)
*/
error(message: string, ...args: any[]): void {
if (this.shouldLog('error')) {
// eslint-disable-next-line no-console
console.error(`[${this.context}] ${message}`, ...args);
}
}
/**
* Log critical errors that should ALWAYS be visible regardless of log level
* Use sparingly - only for fatal errors, security issues, or data corruption
*/
critical(message: string, ...args: any[]): void {
// eslint-disable-next-line no-console
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
}
}
/**
* Factory function to create loggers with automatic context detection
*/
export const createLogger = (context: string): AppLogger => {
return new AppLogger(context);
};
@@ -1,345 +0,0 @@
import { FirefliesApiClient } from './fireflies-api-client';
import { MeetingFormatter } from './formatters';
import { createLogger } from './logger';
import { TwentyCrmService } from './twenty-crm-service';
import type { FirefliesWebhookPayload, ProcessResult } from './types';
import { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
import {
getWebhookSecretFingerprint,
isValidFirefliesPayload,
verifyWebhookSignature
} from './webhook-validator';
declare const process: { env: Record<string, string | undefined> };
const logger = createLogger('fireflies');
export class WebhookHandler {
private debug: string[] = [];
private isTestEnvironment: boolean;
constructor() {
this.isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
}
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
const result: ProcessResult = {
success: false,
noteIds: [],
newContacts: [],
errors: [],
};
try {
logger.debug('invoked');
logger.debug(`apiUrl=${getApiUrl()}`);
// 0) Validate environment configuration
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!firefliesApiKey) {
logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
throw new Error('FIREFLIES_API_KEY environment variable is required');
}
if (!twentyApiKey) {
logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
throw new Error('TWENTY_API_KEY environment variable is required');
}
// 1) Parse and validate webhook payload and extract headers if wrapped together
const { payload, extractedHeaders } = this.parsePayload(params);
const finalHeaders = extractedHeaders || headers;
logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
// 2) Verify webhook signature
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
this.verifySignature(payload, finalHeaders, webhookSecret);
logger.debug('signature verification: ok');
// 3) Fetch meeting data from Fireflies
const summaryConfig = getSummaryFetchConfig();
logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
logger.debug(`fetching meeting data from Fireflies API`);
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
payload.meetingId,
summaryConfig
);
logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
result.summaryReady = summaryReady;
result.summaryPending = !summaryReady;
// Extract business intelligence
if (summaryReady) {
result.actionItemsCount = meetingData.summary.action_items.length;
result.keyTopics = meetingData.summary.topics_discussed;
result.meetingType = meetingData.summary.meeting_type;
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
result.sentimentScore = sentiments.positive_pct / 100;
}
}
// 4) Check for duplicate meetings
const twentyService = new TwentyCrmService(
twentyApiKey,
getApiUrl()
);
const existingMeeting = await twentyService.findExistingMeeting(meetingData.title);
if (existingMeeting) {
logger.debug(`meeting already exists id=${existingMeeting.id}`);
result.success = true;
result.meetingId = existingMeeting.id;
result.debug = this.debug;
return result;
}
logger.debug('no existing meeting found, proceeding');
// 5) Match participants to existing contacts
logger.debug(`total participants from API: ${meetingData.participants.length}`);
meetingData.participants.forEach((p, idx) => {
logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
meetingData.participants
);
logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
unmatchedParticipants.forEach((p, idx) => {
logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
// 6) Optionally create contacts
const autoCreate = shouldAutoCreateContacts();
const newContactIds = autoCreate
? await twentyService.createContactsForUnmatched(unmatchedParticipants)
: [];
result.newContacts = newContactIds;
logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
// 7) Create note first (so we can link to it from the meeting)
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
const noteId = await twentyService.createNoteOnly(
`Meeting: ${meetingData.title}`,
noteBody
);
result.noteIds = [noteId];
logger.debug(`created note id=${noteId}`);
// 8) Create meeting with direct relationship to the note
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
result.meetingId = await twentyService.createMeeting(meetingInput);
logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
// 9) Link note to participants (Meeting link is handled via the relation field)
await this.linkNoteToParticipants(
twentyService,
noteId,
allContactIds
);
logger.debug(`linked note to ${allContactIds.length} participants`);
result.success = true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error(`error: ${message}`);
result.errors?.push(message);
// Try to create a failed meeting record for tracking
await this.createFailedMeetingRecord(params, message);
}
result.debug = this.debug;
return result;
}
private parsePayload(params: unknown): { payload: FirefliesWebhookPayload; extractedHeaders?: Record<string, string> } {
let normalizedParams = params;
let extractedHeaders: Record<string, string> | undefined;
// Handle string-encoded params
if (typeof normalizedParams === 'string') {
logger.debug(`received params as string length=${normalizedParams.length}`);
try {
const parsed = JSON.parse(normalizedParams);
normalizedParams = parsed;
if (parsed && typeof parsed === 'object') {
const parsedKeys = Object.keys(parsed as Record<string, unknown>);
logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
}
} catch (parseError) {
logger.error(`error parsing string params: ${String(parseError)}`);
throw new Error('Invalid or missing webhook payload');
}
}
// Handle wrapped payloads and extract headers if present
let payload: FirefliesWebhookPayload | undefined;
if (isValidFirefliesPayload(normalizedParams)) {
payload = normalizedParams as FirefliesWebhookPayload;
} else if (normalizedParams && typeof normalizedParams === 'object') {
const wrapper = normalizedParams as Record<string, unknown>;
// Extract headers if present in wrapper
if (wrapper.headers && typeof wrapper.headers === 'object' && !Array.isArray(wrapper.headers)) {
extractedHeaders = wrapper.headers as Record<string, string>;
const headerKeys = Object.keys(extractedHeaders);
logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
}
const wrapperKeys = ['params', 'payload', 'body', 'data', 'event'];
for (const key of wrapperKeys) {
const candidate = wrapper[key];
if (isValidFirefliesPayload(candidate)) {
logger.debug(`detected payload under wrapper key "${key}"`);
payload = candidate as FirefliesWebhookPayload;
break;
}
}
}
if (!payload) {
logger.error('error: Invalid or missing webhook payload');
throw new Error('Invalid or missing webhook payload');
}
// Log payload keys for debugging
const payloadRecord = payload as Record<string, unknown>;
const payloadKeys = Object.keys(payloadRecord);
if (payloadKeys.length > 0) {
logger.debug(`payload keys: ${payloadKeys.join(',')}`);
}
return { payload, extractedHeaders };
}
private verifySignature(
payload: FirefliesWebhookPayload,
headers: Record<string, string> | undefined,
webhookSecret: string
): void {
// Extract headers
const normalizedHeaders = headers || {};
const headerKeys = Object.keys(normalizedHeaders);
if (headerKeys.length > 0) {
logger.debug(`header keys: ${headerKeys.join(',')}`);
}
const headerSignature = Object.entries(normalizedHeaders).find(
([key]) => key.toLowerCase() === 'x-hub-signature',
)?.[1];
const payloadRecord = payload as Record<string, unknown>;
const payloadSignature =
typeof payloadRecord['x-hub-signature'] === 'string'
? (payloadRecord['x-hub-signature'] as string)
: undefined;
if (payloadSignature) {
logger.debug('found signature inside payload');
}
const signature =
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
const body = typeof normalizedHeaders['body'] === 'string'
? normalizedHeaders['body']
: JSON.stringify(payloadRecord);
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
if (!signatureCheck.isValid) {
logger.debug(
`signature check failed. headerPresent=${Boolean(
headerSignature,
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
);
if (signature) {
logger.debug(`provided signature=${signature}`);
} else {
logger.debug('provided signature=undefined');
}
logger.debug(
`computed signature=${signatureCheck.computedSignature ?? 'unavailable'}`,
);
logger.critical('Invalid webhook signature - potential security threat detected in production');
throw new Error('Invalid webhook signature');
}
}
private async linkNoteToParticipants(
twentyService: TwentyCrmService,
noteId: string,
contactIds: string[]
): Promise<void> {
// Create Note-Person links for each participant
for (const contactId of contactIds) {
try {
await twentyService.createNoteTarget(noteId, contactId);
logger.debug(`linked note ${noteId} to person ${contactId}`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error(`failed to link note to person ${contactId}: ${message}`);
// Continue with other participants
}
}
}
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
try {
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!twentyApiKey) {
logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
return;
}
// Try to extract meeting ID and title from the params
let meetingId = 'unknown';
let meetingTitle = 'Unknown Meeting';
const { payload } = this.parsePayload(params);
if (payload?.meetingId) {
meetingId = payload.meetingId;
// Try to get meeting title from Fireflies API if possible
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
if (firefliesApiKey) {
try {
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
meetingTitle = meetingData.title || meetingTitle;
} catch (fetchError) {
logger.debug(`Could not fetch meeting title: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`);
}
}
}
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
const failedMeetingData = MeetingFormatter.toFailedMeetingCreateInput(
meetingId,
meetingTitle,
error
);
const failedMeetingId = await twentyService.createFailedMeeting(failedMeetingData);
logger.debug(`Created failed meeting record: ${failedMeetingId}`);
} catch (recordError) {
// Don't throw here - we don't want to break the original error handling
logger.error(`Failed to create failed meeting record: ${recordError instanceof Error ? recordError.message : 'Unknown error'}`);
}
}
}
@@ -1,26 +0,0 @@
{
"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"
]
}
File diff suppressed because it is too large Load Diff
@@ -1,21 +0,0 @@
# Last email interaction
Updates Last interaction and Interaction status fields based on last email date
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
## Setup
1. Add and synchronize app
```bash
twenty auth login
cd last_email_interaction
twenty app sync
```
2. Go to Settings > Integrations > Last email interaction > Settings and add required variables
## Flow
- Checks if fields are created, if not, creates them on fly
- Extracts the datetime of message and calculates the last interaction status
- Fetches all users and companies connected to them and updates their Last interaction and Interaction status fields
@@ -1,22 +0,0 @@
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,
description: 'Required to send requests to Twenty',
},
TWENTY_API_URL: {
universalIdentifier: '6d19bb04-45bb-46aa-a4e5-4a2682c7b19d',
isSecret: false,
description: 'Optional, defaults to cloud API URL',
},
},
};
export default config;
@@ -1,18 +0,0 @@
{
"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",
"twenty-sdk": "0.0.4"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,277 +0,0 @@
import axios from 'axios';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '';
const TWENTY_URL =
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
? `${process.env.TWENTY_API_URL}/rest`
: 'https://api.twenty.com/rest';
const create_last_interaction = (id: string) => {
return {
method: 'POST',
url: `${TWENTY_URL}/metadata/fields`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
data: {
type: 'DATE_TIME',
objectMetadataId: `${id}`,
name: 'lastInteraction',
label: 'Last interaction',
description: 'Date when the last interaction happened',
icon: 'IconCalendarClock',
defaultValue: null,
isNullable: true,
settings: {},
},
};
};
const create_interaction_status = (id: string) => {
return {
method: 'POST',
url: `${TWENTY_URL}/metadata/fields`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
data: {
type: 'SELECT',
objectMetadataId: `${id}`,
name: 'interactionStatus',
label: 'Interaction status',
description: 'Indicates the health of relation',
icon: 'IconProgress',
defaultValue: null,
isNullable: true,
settings: {},
options: [
{
color: 'green',
label: 'Recent',
value: 'RECENT',
position: 1,
},
{
color: 'yellow',
label: 'Active',
value: 'ACTIVE',
position: 2,
},
{
color: 'sky',
label: 'Cooling',
value: 'COOLING',
position: 3,
},
{
color: 'gray',
label: 'Dormant',
value: 'DORMANT',
position: 4,
},
],
},
};
};
const calculateStatus = (date: string) => {
const day = 1000 * 60 * 60 * 24;
const now = Date.now();
const messageDate = Date.parse(date);
const deltaTime = now - messageDate;
return deltaTime < 7 * day
? 'RECENT'
: deltaTime < 30 * day
? 'ACTIVE'
: deltaTime < 90 * day
? 'COOLING'
: 'DORMANT';
};
const interactionData = (date: string, status: string) => {
return {
lastInteraction: date,
interactionStatus: status,
};
};
const updateInteractionStatus = async (objectName: string, id: string, messageDate: string, status: string) => {
const options = {
method: 'PATCH',
url: `${TWENTY_URL}/${objectName}/${id}`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
data: {
lastInteraction: messageDate,
interactionStatus: status
}
};
try {
const response = await axios.request(options);
if (response.status === 200) {
console.log('Successfully updated company last interaction field');
}
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
throw error;
}
}
const fetchRelatedCompanyId = async (id: string) => {
const options = {
method: 'GET',
url: `${TWENTY_URL}/people/${id}`,
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
};
try {
const req = await axios.request(options);
if (req.status === 200 && req.data.person.companyId !== null && req.data.person.companyId !== undefined) {
return req.data.person.companyId;
}
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
throw error;
}
}
export const main = async (params: {
properties: Record<string, any>;
recordId: string;
userId: string;
}): Promise<object | undefined> => {
if (TWENTY_API_KEY === '') {
console.log("Function exited as API key or URL hasn't been set properly");
return {};
}
const { properties, recordId } = params;
// Check if fields are created
const options = {
method: 'GET',
url: `${TWENTY_URL}/metadata/objects`,
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
};
try {
const response = await axios.request(options);
const objects = response.data.data.objects;
const company_object = objects.find(
(object: any) => object.nameSingular === 'company',
);
const company_last_interaction = company_object.fields.find(
(field: any) => field.name === 'lastInteraction',
);
const company_interaction_status = company_object.fields.find(
(field: any) => field.name === 'interactionStatus',
);
const person_object = objects.find(
(object: any) => object.nameSingular === 'person',
);
const person_last_interaction = person_object.fields.find(
(field: any) => field.name === 'lastInteraction',
);
const person_interaction_status = person_object.fields.find(
(field: any) => field.name === 'interactionStatus',
);
// If not, create them
if (company_last_interaction === undefined) {
const response2 = await axios.request(
create_last_interaction(company_object.id),
);
if (response2.status === 201) {
console.log('Successfully created company last interaction field');
}
}
if (company_interaction_status === undefined) {
const response2 = await axios.request(
create_interaction_status(company_object.id),
);
if (response2.status === 201) {
console.log('Successfully created company interaction status field');
}
}
if (person_last_interaction === undefined) {
const response2 = await axios.request(
create_last_interaction(person_object.id),
);
if (response2.status === 201) {
console.log('Successfully created person last interaction field');
}
}
if (person_interaction_status === undefined) {
const response2 = await axios.request(
create_interaction_status(person_object.id),
);
if (response2.status === 201) {
console.log('Successfully created person interaction status field');
}
}
// Extract the timestamp of message
const messageDate = properties.after.receivedAt;
const interactionStatus = calculateStatus(messageDate);
// Get the details of person and related company
const messageOptions = {
method: 'GET',
url: `${TWENTY_URL}/messages/${recordId}?depth=1`,
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
};
const messageDetails = await axios.request(messageOptions);
const peopleIds: string[] = [];
for (const participant of messageDetails.data.messages
.messageParticipants) {
peopleIds.push(participant.personId);
}
const companiesIds = [];
for (const id of peopleIds) {
companiesIds.push(await fetchRelatedCompanyId(id));
}
// Update the field value depending on the timestamp
for (const id of peopleIds) {
await updateInteractionStatus("people", id, messageDate, interactionStatus);
}
for (const id of companiesIds) {
await updateInteractionStatus("companies", id, messageDate, interactionStatus);
}
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.message);
return {};
}
console.error(error);
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',
},
],
};
@@ -1,26 +0,0 @@
{
"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,273 +0,0 @@
# 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
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
languageName: node
linkType: hard
"async-generator-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-generator-function@npm:1.0.0"
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
languageName: node
linkType: hard
"asynckit@npm:^0.4.0":
version: 0.4.0
resolution: "asynckit@npm:0.4.0"
checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
languageName: node
linkType: hard
"axios@npm:^1.12.2":
version: 1.12.2
resolution: "axios@npm:1.12.2"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.4"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/80b063e318cf05cd33a4d991cea0162f3573481946f9129efb7766f38fde4c061c34f41a93a9f9521f02b7c9565ccbc197c099b0186543ac84a24580017adfed
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
dependencies:
delayed-stream: "npm:~1.0.0"
checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
languageName: node
linkType: hard
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.3.0":
version: 1.3.0
resolution: "es-errors@npm:1.3.0"
checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85
languageName: node
linkType: hard
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.1.0":
version: 2.1.0
resolution: "es-set-tostringtag@npm:2.1.0"
dependencies:
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.6"
has-tostringtag: "npm:^1.0.2"
hasown: "npm:^2.0.2"
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
languageName: node
linkType: hard
"follow-redirects@npm:^1.15.6":
version: 1.15.11
resolution: "follow-redirects@npm:1.15.11"
peerDependenciesMeta:
debug:
optional: true
checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
languageName: node
linkType: hard
"form-data@npm:^4.0.4":
version: 4.0.4
resolution: "form-data@npm:4.0.4"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5
languageName: node
linkType: hard
"generator-function@npm:^2.0.0":
version: 2.0.1
resolution: "generator-function@npm:2.0.1"
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.6":
version: 1.3.1
resolution: "get-intrinsic@npm:1.3.1"
dependencies:
async-function: "npm:^1.0.0"
async-generator-function: "npm:^1.0.0"
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
generator-function: "npm:^2.0.0"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
languageName: node
linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.2":
version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2"
dependencies:
has-symbols: "npm:^1.0.3"
checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c
languageName: node
linkType: hard
"hasown@npm:^2.0.2":
version: 2.0.2
resolution: "hasown@npm:2.0.2"
dependencies:
function-bind: "npm:^1.1.2"
checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9
languageName: node
linkType: hard
"last-email-interaction@workspace:.":
version: 0.0.0-use.local
resolution: "last-email-interaction@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
axios: "npm:^1.12.2"
twenty-sdk: "npm:^0.0.4"
languageName: unknown
linkType: soft
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
languageName: node
linkType: hard
"mime-db@npm:1.52.0":
version: 1.52.0
resolution: "mime-db@npm:1.52.0"
checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
languageName: node
linkType: hard
"mime-types@npm:^2.1.12":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
mime-db: "npm:1.52.0"
checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
languageName: node
linkType: hard
"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
languageName: node
linkType: hard
"twenty-sdk@npm:^0.0.4":
version: 0.0.4
resolution: "twenty-sdk@npm:0.0.4"
checksum: 10c0/550f1d85bf0701396c9dd2d4c6bc55ba1b067fce13636f8540eec60ab6a4257c6d7cd86cb3f62e0974bf99467bc31270d92b17b9681a1b7a6281b7ef97224080
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
@@ -1,23 +0,0 @@
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,17 +0,0 @@
{
"name": "browser-extension",
"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": {
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,34 +0,0 @@
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: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
body: JSON.stringify({
name: params.name,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
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,40 +0,0 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (params: {
firstName: string;
lastName: string;
}): Promise<object> => {
const response = await fetch(`${process.env.TWENTY_API_URL}/rest/people`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
body: JSON.stringify({
name: {
firstName: params.firstName,
lastName: params.lastName,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
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,28 +0,0 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (params: {
a: string;
b: number;
}): Promise<object> => {
const { a, b } = params;
// Rename the parameters and code below with your own logic
// 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,28 +0,0 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (params: {
a: string;
b: number;
}): Promise<object> => {
const { a, b } = params;
// Rename the parameters and code below with your own logic
// 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,
},
],
};
@@ -1,26 +0,0 @@
{
"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,30 +0,0 @@
# 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
@@ -1,33 +0,0 @@
# Mailchimp synchronizer
Synchronizing contacts between Twenty and Mailchimp
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
- Mailchimp API key - Mailchimp > avatar in top right corner > Profile > Extras > API keys
## Setup
1. Add app to your workspace
```bash
twenty auth login
cd mailchimp-synchronizer
twenty app sync
```
2. Go to Settings > Integrations > Mailchimp synchronizer > Settings and add required variables
## Flow
- Check if required variables are set, if not, exit
- Validate data based on set constraints, if data doesn't match constraints, exit
- Check if person already exists in Mailchimp:
- if yes, check if UPDATE_PERSON is set to true
- if UPDATE_PERSON is true, check if Twenty record is the same as Mailchimp record
- if they're the same, exit
- if not, update
- if UPDATE_PERSON is false, exit
- if person doesn't exist in Mailchimp, send a request to Mailchimp with new contact
## Note
- SMS support is experimental and may cause errors
- constraints are directly responsible for sent data so if e.g. you want to have a company name in
Mailchimp, you have to set IS_COMPANY_CONSTRAINT to true
@@ -1,71 +0,0 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '1eadac4e-db9f-4cce-b20b-de75f41e34dc',
displayName: 'Mailchimp synchronizer',
description: 'Synchronizes Twenty contacts in Mailchimp',
icon: "IconMailFast",
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '0af17af3-66b8-40cf-b6e2-6a29a1da5464',
isSecret: true,
description: 'Required to send requests to Twenty',
},
TWENTY_API_URL: {
universalIdentifier: '12949c1c-aed7-4a9f-bd06-9fd15f0bfa63',
isSecret: false,
description: 'Optional, defaults to cloud API URL',
},
MAILCHIMP_API_KEY: {
universalIdentifier: 'f10d4e8a-8055-4eb2-b9ad-efd69d43b1f0',
isSecret: true,
description: 'Required to send requests to Mailchimp',
},
MAILCHIMP_SERVER_PREFIX: {
universalIdentifier: '6c8b6ac9-dd45-4f0b-a397-c4a38edccfd9',
isSecret: false,
description: 'Required to send requests to Mailchimp (it\'s found in url, e.g. https://us9.admin.mailchimp.com > us9 is prefix)',
},
MAILCHIMP_AUDIENCE_ID: {
universalIdentifier: '5492f06f-bb29-4c93-9436-b4736a396376',
isSecret: false,
description: 'Required to send requests to Mailchimp',
},
IS_EMAIL_CONSTRAINT: {
universalIdentifier: '62626c57-470f-4866-be1e-5b4d7ec09f9f',
isSecret: false,
value: 'false',
description:
'Set to true if you want to add additional constraint (default is false)',
},
IS_PHONE_CONSTRAINT: {
universalIdentifier: 'fac8ec5b-dade-46bf-b938-3dfdef0aa298',
isSecret: false,
value: 'false',
description:
'Set to true if you want to add additional constraint (default is false)',
},
IS_COMPANY_CONSTRAINT: {
universalIdentifier: '9ffd8e76-4ab2-42f9-8549-3622a5ae2343',
isSecret: false,
value: 'false',
description:
'Set to true if you want to add additional constraint (default is false)',
},
IS_ADDRESS_CONSTRAINT: {
universalIdentifier: '4b899eb6-517e-4afd-bbf8-88097900ea42',
isSecret: false,
value: 'false',
description:
'Set to true if you want to add additional constraint (default is false)',
},
UPDATE_PERSON: {
universalIdentifier: '9d753e1e-4408-40ca-b0f0-5c7e8625c2aa',
isSecret: true,
value: 'false',
description: 'Set to true if you want to update record in Mailchimp if it exists',
},
},
};
export default config;
@@ -1,18 +0,0 @@
{
"name": "mailchimp-synchronizer",
"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.13.2",
"twenty-sdk": "0.0.4"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,454 +0,0 @@
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
? `${process.env.TWENTY_API_URL}/rest`
: 'https://api.twenty.com/rest';
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
const MAILCHIMP_API_URL: string =
process.env.MAILCHIMP_SERVER_PREFIX !== '' &&
process.env.MAILCHIMP_SERVER_PREFIX !== undefined
? `https://${process.env.MAILCHIMP_SERVER_PREFIX}.api.mailchimp.com/3.0/`
: '';
const MAILCHIMP_API_KEY: string = process.env.MAILCHIMP_API_KEY ?? '';
const MAILCHIMP_AUDIENCE_ID: string = process.env.MAILCHIMP_AUDIENCE_ID ?? '';
const IS_EMAIL_CONSTRAINT: boolean = process.env.IS_EMAIL_CONSTRAINT === 'true';
const IS_COMPANY_CONSTRAINT: boolean =
process.env.COMPANY_CONSTRAINT === 'true';
const IS_PHONE_CONSTRAINT: boolean = process.env.IS_PHONE_CONSTRAINT === 'true';
const IS_ADDRESS_CONSTRAINT: boolean =
process.env.IS_ADDRESS_CONSTRAINT === 'true';
const UPDATE_PERSON: boolean = process.env.UPDATE_PERSON === 'true';
type mailchimpAddress = {
street1: string;
street2: string;
city: string;
state: string;
zipCode: string;
country: string;
};
type mailchimpRecord = {
id?: string;
email_channel?: {
email: string;
marketing_consent?: {
status: string;
};
};
sms_channel?: {
sms_phone: string;
marketing_consent?: {
status: string;
};
};
mergeFields: {
FNAME: string;
LNAME: string;
ADDRESS: string | mailchimpAddress;
COMPANY: string;
PHONE: string;
};
};
type twentyAddress = {
addressStreet1: string;
addressStreet2: string;
addressCity: string;
addressState: string;
addressPostCode: string;
addressCountry: string;
};
type twentyCompany = {
name: string;
address: twentyAddress;
};
type twentyPerson = {
name: {
firstName: string;
lastName: string;
};
emails: {
primaryEmail: string;
};
phones: {
primaryPhoneNumber: string;
primaryPhoneCallingCode: string;
};
companyId: string | null;
};
const fetchCompanyData = async (companyId: string): Promise<twentyCompany> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/company/${companyId}`,
};
try {
const response = await axios.request(options);
return response.status === 200
? ({
name: response.data.name as string,
address: response.data.address as twentyAddress,
} as twentyCompany)
: ({} as twentyCompany);
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
throw error;
}
};
const checkAddress = (address: twentyAddress): mailchimpAddress => {
if (
address.addressStreet1 !== '' &&
address.addressCity !== '' &&
address.addressPostCode !== '' &&
address.addressState !== ''
) {
return {
street1: address.addressStreet1,
street2: address.addressStreet2,
city: address.addressCity,
state: address.addressState,
zipCode: address.addressPostCode,
country: address.addressCountry,
} as mailchimpAddress;
}
throw new Error('Invalid address');
};
const checkAudiencePermissions = async (
audienceId: string,
): Promise<string[] | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
},
url: `${MAILCHIMP_API_URL}/audiences/${audienceId}`,
};
try {
const temp = await axios.request(options);
return temp.data.enabled_channels;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const prepareData = (
firstName: string,
lastName: string,
email: string,
phoneNumber: string,
phoneCallingCode: string,
companyName: string,
address: mailchimpAddress | string,
): mailchimpRecord => {
let data = {
mergeFields: {},
} as mailchimpRecord;
data.mergeFields.FNAME = firstName;
data.mergeFields.LNAME = lastName;
if (IS_EMAIL_CONSTRAINT) {
data.email_channel = {
email: email,
marketing_consent: {
status: 'unknown',
},
};
}
if (IS_ADDRESS_CONSTRAINT) {
data.mergeFields.ADDRESS = address;
}
if (IS_PHONE_CONSTRAINT) {
const mergedPhoneNumber: string = phoneCallingCode.startsWith('+')
? phoneCallingCode.concat(phoneNumber)
: '+'.concat(phoneCallingCode, phoneNumber);
data['sms_channel'] = {
sms_phone: mergedPhoneNumber,
marketing_consent: {
status: 'unknown',
},
};
data['mergeFields']['PHONE'] = mergedPhoneNumber;
}
if (IS_COMPANY_CONSTRAINT) {
data['mergeFields']['COMPANY'] = companyName;
}
return data;
};
const addTwentyPersonToMailchimp = async (
convertedRecord: mailchimpRecord,
): Promise<boolean | undefined> => {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
},
url: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts`,
data: convertedRecord,
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfTwentyPersonExistsInMailchimp = async (
email?: string,
phoneNumber?: string,
cursor?: string,
) => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
},
url: cursor
? `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts?count%3D1000%26cursor%3D${cursor}`
: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts?count%3D1000`,
};
try {
const response = await axios.request(options);
const doesPersonExist: mailchimpRecord | undefined =
(response.data.contacts.find(
(contact: any) => contact.email_channel.email === email,
) as mailchimpRecord) ||
(response.data.contacts.find(
(contact: any) => contact.sms_channel.sms_phone === phoneNumber,
) as mailchimpRecord);
if (doesPersonExist !== undefined) {
return doesPersonExist;
}
if (response.data.next_cursor === undefined) {
return undefined;
} else {
await checkIfTwentyPersonExistsInMailchimp(
email,
phoneNumber,
response.data.next_cursor,
);
}
return undefined;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const compareTwoRecords = (
object1: mailchimpRecord,
object2: mailchimpRecord,
) => {
return (
object1.email_channel?.email === object2.email_channel?.email &&
object1.sms_channel?.sms_phone === object2.sms_channel?.sms_phone &&
object1.mergeFields.FNAME === object2.mergeFields.FNAME &&
object1.mergeFields.LNAME === object2.mergeFields.LNAME &&
object1.mergeFields.ADDRESS === object2.mergeFields.ADDRESS &&
object1.mergeFields.COMPANY === object2.mergeFields.COMPANY
);
};
const updateTwentyPersonInMailchimp = async (
mailchimpRecordId: string,
twentyConvertedRecord: mailchimpRecord,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts/${mailchimpRecordId}`,
data: twentyConvertedRecord,
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
export const main = async (params: {
properties: Record<string, any>;
recordId: string;
userId: string;
}): Promise<object | undefined> => {
if (!IS_EMAIL_CONSTRAINT && !IS_PHONE_CONSTRAINT) {
console.warn(
'Function exited as there are no constraints to email nor phone number',
);
return {};
}
if (
MAILCHIMP_API_URL === '' ||
MAILCHIMP_API_KEY === '' ||
MAILCHIMP_AUDIENCE_ID === ''
) {
console.warn('Missing Mailchimp required parameters');
return {};
}
if (IS_COMPANY_CONSTRAINT && TWENTY_API_KEY === '') {
console.warn('Missing Twenty related parameters');
return {};
}
try {
const { properties } = params;
const twentyRecord: twentyPerson = properties.after as twentyPerson;
if (
twentyRecord.name.firstName === '' ||
twentyRecord.name.lastName === ''
) {
throw new Error('First or last name is empty');
}
const audiencePermissions: string[] | undefined =
await checkAudiencePermissions(MAILCHIMP_AUDIENCE_ID);
if (
IS_EMAIL_CONSTRAINT &&
audiencePermissions?.includes('Email') &&
twentyRecord.emails.primaryEmail === ''
) {
throw new Error('Email is empty');
}
if (
IS_PHONE_CONSTRAINT &&
audiencePermissions?.includes('SMS') &&
(twentyRecord.phones.primaryPhoneNumber === '' ||
twentyRecord.phones.primaryPhoneCallingCode === '')
) {
throw new Error('Phone number is empty');
}
let companyName: string = '';
let address: mailchimpAddress | string = '';
if (IS_COMPANY_CONSTRAINT) {
if (twentyRecord.companyId === null) {
throw new Error('Missing relation to company record');
}
const company: twentyCompany = await fetchCompanyData(
twentyRecord.companyId,
);
companyName = company.name ?? ''; // either "" or name
address = IS_ADDRESS_CONSTRAINT ? checkAddress(company.address) : '';
}
const twentyPersonToMailchimpRecord: mailchimpRecord = prepareData(
twentyRecord.name.firstName,
twentyRecord.name.lastName,
twentyRecord.emails.primaryEmail,
twentyRecord.phones.primaryPhoneNumber,
twentyRecord.phones.primaryPhoneCallingCode,
companyName,
address,
);
console.log(twentyPersonToMailchimpRecord);
const isTwentyPersonInMailchimp: mailchimpRecord | undefined =
await checkIfTwentyPersonExistsInMailchimp(
twentyRecord.emails.primaryEmail,
twentyPersonToMailchimpRecord.sms_channel?.sms_phone,
);
if (isTwentyPersonInMailchimp !== undefined) {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} found in Mailchimp`,
);
if (UPDATE_PERSON) {
if (
!compareTwoRecords(
isTwentyPersonInMailchimp,
twentyPersonToMailchimpRecord,
) &&
isTwentyPersonInMailchimp.id
) {
const isTwentyPersonUpdatedInMailchimp: boolean | undefined =
await updateTwentyPersonInMailchimp(
isTwentyPersonInMailchimp.id,
twentyPersonToMailchimpRecord,
);
if (!isTwentyPersonUpdatedInMailchimp) {
throw new Error(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} update in Mailchimp failed`,
);
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} update in Mailchimp succeeded`,
);
}
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} wasn't updated in Mailchimp because they're the same`,
);
}
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} wasn't updated in Mailchimp because UPDATE_PERSON is set to false`,
);
}
} else {
console.log(
`${twentyRecord.name.firstName} ${twentyRecord.name.lastName} doesn't exist in Mailchimp, adding`,
);
const isTwentyPersonAddedToMailchimp: boolean | undefined =
await addTwentyPersonToMailchimp(twentyPersonToMailchimpRecord);
if (!isTwentyPersonAddedToMailchimp) {
throw new Error(
`Adding ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} person to Mailchimp failed`,
);
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} has been successfully added`,
);
}
}
return {};
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
return {};
}
console.error(error);
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',
},
{
universalIdentifier: '657ece26-4478-4408-a257-4e9e16cce279',
type: 'databaseEvent',
eventName: 'person.updated',
},
],
};
@@ -1,26 +0,0 @@
{
"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,273 +0,0 @@
# 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
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
languageName: node
linkType: hard
"async-generator-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-generator-function@npm:1.0.0"
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
languageName: node
linkType: hard
"asynckit@npm:^0.4.0":
version: 0.4.0
resolution: "asynckit@npm:0.4.0"
checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
languageName: node
linkType: hard
"axios@npm:^1.13.2":
version: 1.13.2
resolution: "axios@npm:1.13.2"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.4"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/e8a42e37e5568ae9c7a28c348db0e8cf3e43d06fcbef73f0048669edfe4f71219664da7b6cc991b0c0f01c28a48f037c515263cb79be1f1ae8ff034cd813867b
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
dependencies:
delayed-stream: "npm:~1.0.0"
checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
languageName: node
linkType: hard
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.3.0":
version: 1.3.0
resolution: "es-errors@npm:1.3.0"
checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85
languageName: node
linkType: hard
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.1.0":
version: 2.1.0
resolution: "es-set-tostringtag@npm:2.1.0"
dependencies:
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.6"
has-tostringtag: "npm:^1.0.2"
hasown: "npm:^2.0.2"
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
languageName: node
linkType: hard
"follow-redirects@npm:^1.15.6":
version: 1.15.11
resolution: "follow-redirects@npm:1.15.11"
peerDependenciesMeta:
debug:
optional: true
checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
languageName: node
linkType: hard
"form-data@npm:^4.0.4":
version: 4.0.4
resolution: "form-data@npm:4.0.4"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5
languageName: node
linkType: hard
"generator-function@npm:^2.0.0":
version: 2.0.1
resolution: "generator-function@npm:2.0.1"
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.6":
version: 1.3.1
resolution: "get-intrinsic@npm:1.3.1"
dependencies:
async-function: "npm:^1.0.0"
async-generator-function: "npm:^1.0.0"
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
generator-function: "npm:^2.0.0"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
languageName: node
linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.2":
version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2"
dependencies:
has-symbols: "npm:^1.0.3"
checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c
languageName: node
linkType: hard
"hasown@npm:^2.0.2":
version: 2.0.2
resolution: "hasown@npm:2.0.2"
dependencies:
function-bind: "npm:^1.1.2"
checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9
languageName: node
linkType: hard
"mailchimp-synchronizer@workspace:.":
version: 0.0.0-use.local
resolution: "mailchimp-synchronizer@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
axios: "npm:^1.13.2"
twenty-sdk: "npm:^0.0.4"
languageName: unknown
linkType: soft
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
languageName: node
linkType: hard
"mime-db@npm:1.52.0":
version: 1.52.0
resolution: "mime-db@npm:1.52.0"
checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
languageName: node
linkType: hard
"mime-types@npm:^2.1.12":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
mime-db: "npm:1.52.0"
checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
languageName: node
linkType: hard
"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
languageName: node
linkType: hard
"twenty-sdk@npm:^0.0.4":
version: 0.0.4
resolution: "twenty-sdk@npm:0.0.4"
checksum: 10c0/550f1d85bf0701396c9dd2d4c6bc55ba1b067fce13636f8540eec60ab6a4257c6d7cd86cb3f62e0974bf99467bc31270d92b17b9681a1b7a6281b7ef97224080
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
@@ -1,46 +0,0 @@
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;
@@ -1,25 +0,0 @@
{
"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",
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.7.0",
"openai": "^4.67.0",
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/node": "^24.7.2",
"typescript": "^5.6.0"
}
}
@@ -1,872 +0,0 @@
import axios from 'axios';
import OpenAI from 'openai';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
type TranscriptWebhookPayload = {
transcript: string;
relatedPersonId: string;
meetingTitle?: string;
meetingDate?: string;
participants?: string[];
metadata?: Record<string, unknown>;
token?: string;
};
type ActionItem = {
title: string;
description: string;
assignee?: string;
dueDate?: string;
};
type Commitment = {
person: string;
commitment: string;
dueDate?: string;
};
type AnalysisResult = {
summary: string;
keyPoints: string[];
actionItems: ActionItem[];
commitments: Commitment[];
};
type RichTextV2Data = {
markdown: string;
blocknote: null;
};
type TwentyApiResponse = {
id: string;
};
const AI_PROVIDER_API_KEY = process.env.AI_PROVIDER_API_KEY;
const WEBHOOK_SECRET_TOKEN = process.env.WEBHOOK_SECRET_TOKEN;
const TWENTY_API_URL = process.env.TWENTY_API_URL;
const AI_PROVIDER_API_BASE_URL = process.env.AI_PROVIDER_API_BASE_URL;
const LLM_MODEL_ID = 'openai/gpt-oss-20b';
const OPENAI_TEMPERATURE = 0.3;
const openai = new OpenAI({
apiKey: AI_PROVIDER_API_KEY,
baseURL: AI_PROVIDER_API_BASE_URL,
});
const getTwentyApiConfig = () => {
const apiKey = process.env.TWENTY_API_KEY;
if (!apiKey) {
throw new Error('TWENTY_API_KEY environment variable is not set');
}
const baseUrl = TWENTY_API_URL;
if (!baseUrl) {
throw new Error('TWENTY_API_URL environment variable is not set');
}
return { apiKey, baseUrl };
};
const lookupWorkspaceMemberByName = async (
name: string,
): Promise<string | null> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
try {
const graphqlQuery = {
query: `
query GetAllWorkspaceMembers {
workspaceMembers {
edges {
node {
id
name {
firstName
lastName
}
}
}
}
}
`,
};
const response = await axios.post(`${baseUrl}/graphql`, graphqlQuery, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
const edges = response.data?.data?.workspaceMembers?.edges;
if (!edges || edges.length === 0) {
console.log('⚠️ No workspace members found');
return null;
}
const searchName = name.trim().toLowerCase();
for (const edge of edges) {
const firstName = edge.node.name?.firstName || '';
const lastName = edge.node.name?.lastName || '';
const fullName = `${firstName} ${lastName}`.trim().toLowerCase();
if (fullName === searchName) {
console.log(
`✅ Found workspace member (exact): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
for (const edge of edges) {
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})`,
);
return edge.node.id;
}
}
for (const edge of edges) {
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})`,
);
return edge.node.id;
}
}
for (const edge of edges) {
const firstName = edge.node.name?.firstName || '';
const lastName = edge.node.name?.lastName || '';
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})`,
);
return edge.node.id;
}
}
console.log(`⚠️ No workspace member found matching: "${name}"`);
return null;
} catch (error) {
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message;
console.error(
`❌ Failed to lookup workspace member "${name}": ${errorMessage}`,
);
}
return null;
}
};
const lookupPersonByName = async (name: string): Promise<string | null> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
try {
const graphqlQuery = {
query: `
query GetAllPeople {
people {
edges {
node {
id
name {
firstName
lastName
}
}
}
}
}
`,
};
const response = await axios.post(`${baseUrl}/graphql`, graphqlQuery, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
const edges = response.data?.data?.people?.edges;
if (!edges || edges.length === 0) {
console.log('⚠️ No people found in CRM');
return null;
}
const searchName = name.trim().toLowerCase();
for (const edge of edges) {
const firstName = edge.node.name?.firstName || '';
const lastName = edge.node.name?.lastName || '';
const fullName = `${firstName} ${lastName}`.trim().toLowerCase();
if (fullName === searchName) {
console.log(
`✅ Found person (exact): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
for (const edge of edges) {
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})`,
);
return edge.node.id;
}
}
for (const edge of edges) {
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})`,
);
return edge.node.id;
}
}
for (const edge of edges) {
const firstName = edge.node.name?.firstName || '';
const lastName = edge.node.name?.lastName || '';
const fullName = `${firstName} ${lastName}`.trim().toLowerCase();
if (fullName.includes(searchName) || searchName.includes(fullName)) {
console.log(
`✅ Found person (partial): ${firstName} ${lastName} (ID: ${edge.node.id})`,
);
return edge.node.id;
}
}
console.log(`⚠️ No person found matching: "${name}"`);
return null;
} catch (error) {
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message;
console.error(`❌ Failed to lookup person "${name}": ${errorMessage}`);
}
return null;
}
};
const extractPersonNamesFromDescription = (
description: string,
participants: string[],
): string[] => {
const foundNames: string[] = [];
for (const participant of participants) {
if (description.includes(participant)) {
foundNames.push(participant);
continue;
}
const firstName = participant.split(' ')[0];
if (firstName && description.includes(firstName)) {
foundNames.push(participant);
}
}
return foundNames;
};
const formatNoteBody = (summary: string, keyPoints: string[]): string => {
const keyPointsList = keyPoints.map((point) => `- ${point}`).join('\n');
return `## Summary\n\n${summary}\n\n## Key Points\n\n${keyPointsList}\n\n*Generated from meeting transcript*`;
};
const linkNoteToPersonREST = async (
noteId: string,
personId: string,
): Promise<void> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
try {
const response = await axios.post(
`${baseUrl}/rest/noteTargets`,
{
noteId: noteId,
personId: personId,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
},
);
const noteTargetId = response.data?.data?.createNoteTarget?.id;
if (noteTargetId) {
console.log(
`✅ Successfully linked note ${noteId} to person ${personId} (noteTarget: ${noteTargetId})`,
);
} else {
console.warn(`⚠️ Note linking response received but no ID found`);
}
} 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 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;
}
};
const createNoteInTwenty = async (
summary: string,
keyPoints: string[],
relatedPersonId: string,
meetingTitle?: string,
meetingDate?: string,
): Promise<TwentyApiResponse> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
const noteTitle =
meetingTitle ||
`Meeting Notes - ${meetingDate || new Date().toLocaleDateString()}`;
const noteBodyMarkdown = formatNoteBody(summary, keyPoints);
const requestData = {
title: noteTitle,
bodyV2: {
markdown: noteBodyMarkdown,
blocknote: null,
} satisfies RichTextV2Data,
};
try {
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);
const noteId = response.data?.data?.createNote?.id;
if (!noteId) {
const errorMsg = `Note created but ID not found in response. Response structure: ${responseJson}`;
console.error('❌', errorMsg);
throw new Error(errorMsg);
}
console.log(`✅ Note ID extracted: ${noteId}`);
await linkNoteToPersonREST(noteId, relatedPersonId);
return { id: noteId };
} 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;
throw new Error(
`Failed to create note: ${errorMessage}. Status: ${status}`,
);
}
throw error;
}
};
const linkTaskToPersonREST = async (
taskId: string,
personId: string,
): Promise<void> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
try {
const response = await axios.post(
`${baseUrl}/rest/taskTargets`,
{
taskId: taskId,
personId: personId,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
},
);
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}`,
);
}
}
};
const createTaskInTwenty = async (
actionItem: ActionItem,
relatedPersonId?: string,
): Promise<TwentyApiResponse> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
const taskData: {
title: string;
bodyV2: RichTextV2Data;
dueAt?: string;
assigneeId?: string;
} = {
title: actionItem.title,
bodyV2: {
markdown: actionItem.description,
blocknote: null,
},
};
if (actionItem.assignee) {
console.log(`🔍 Looking up assignee: "${actionItem.assignee}"`);
const assigneeId = await lookupWorkspaceMemberByName(actionItem.assignee);
if (assigneeId) {
taskData.assigneeId = 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`,
);
}
}
if (actionItem.dueDate) {
const date = new Date(actionItem.dueDate);
if (!isNaN(date.getTime())) {
taskData.dueAt = date.toISOString();
}
}
try {
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,
);
throw new Error('Task created but ID not found in response');
}
console.log(
`✅ Task created successfully: ${taskId} - "${actionItem.title}"`,
);
if (relatedPersonId) {
await linkTaskToPersonREST(taskId, relatedPersonId);
}
return { id: taskId };
} 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 create task "${actionItem.title}". Status: ${status}, Error: ${errorMessage}`,
);
throw new Error(
`Failed to create task "${actionItem.title}": ${errorMessage}. Status: ${status}`,
);
}
throw error;
}
};
const createTasksFromActionItems = async (
actionItems: ActionItem[],
noteId: string,
relatedPersonId: string,
participants: string[],
): Promise<string[]> => {
const taskIds: string[] = [];
for (const actionItem of actionItems) {
try {
const taskDescription = `${actionItem.description}\n\n*Related to meeting note: ${noteId}*`;
console.log(`Creating task: "${actionItem.title}"`);
const mentionedPeople = extractPersonNamesFromDescription(
actionItem.description,
participants,
);
console.log(`📝 People mentioned in task description:`, mentionedPeople);
const task = await createTaskInTwenty({
...actionItem,
description: taskDescription,
});
taskIds.push(task.id);
console.log(`✅ Task created: ${task.id}`);
if (mentionedPeople.length > 0) {
for (const personName of mentionedPeople) {
const personId = await lookupPersonByName(personName);
if (personId) {
await linkTaskToPersonREST(task.id, personId);
} else {
console.log(
`⚠️ Could not find person "${personName}" in CRM, skipping link`,
);
}
}
} else {
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,
);
}
}
return taskIds;
};
const createTasksFromCommitments = async (
commitments: Commitment[],
noteId: string,
relatedPersonId: string,
): Promise<string[]> => {
const taskIds: string[] = [];
for (const commitment of commitments) {
try {
const taskDescription = `Commitment from ${commitment.person}: ${commitment.commitment}\n\n*Related to meeting note: ${noteId}*`;
const task = await createTaskInTwenty({
title: `Follow up: ${commitment.commitment}`,
description: taskDescription,
assignee: commitment.person,
dueDate: commitment.dueDate || '',
});
taskIds.push(task.id);
const personId = await lookupPersonByName(commitment.person);
if (personId) {
await linkTaskToPersonREST(task.id, personId);
} else {
await linkTaskToPersonREST(task.id, relatedPersonId);
}
} catch (error) {
console.error(
`Commitment task creation failed for "${commitment.commitment}":`,
error,
);
}
}
return taskIds;
};
const analyzeTranscript = async (
transcript: string,
openaiApiKey: string,
): Promise<AnalysisResult> => {
const prompt = `Analyze the following meeting transcript and extract:
1. A concise summary (2-3 sentences)
2. Key discussion points (bullet list)
3. Action items with titles, descriptions, and any mentioned assignees or due dates
🚨 CRITICAL RULE FOR ACTION ITEMS - READ CAREFULLY:
When multiple people are mentioned working on THE SAME deliverable/document/outcome:
→ Create EXACTLY ONE task that represents the complete workflow
→ The task title should describe the MAIN deliverable (what needs to be completed)
→ Assign to the person who will do the FINAL/CRITICAL step (approver, reviewer, coordinator)
→ The description MUST mention ALL people involved and their roles
STRICT EXAMPLES - Follow this pattern exactly:
Example 1:
Transcript: "Brian will finalize the investor deck. Irfan will review it before the presentation next Monday."
❌ WRONG OUTPUT (2 tasks):
Task 1: {"title": "Finalize investor deck", "assignee": "Brian", "description": "Brian will finalize..."}
Task 2: {"title": "Review investor deck", "assignee": "Irfan", "description": "Irfan will review..."}
✅ CORRECT OUTPUT (1 task):
Task 1: {
"title": "Finalize and present investor deck",
"assignee": "Irfan",
"dueDate": "2025-11-03",
"description": "Brian Chesky is designated to finalize the investor deck layout and needs to present it next Monday. Irfan will review the deck before the presentation."
}
Example 2:
Transcript: "Dario will review security protocols before end of week. Iqra will coordinate the security review process."
❌ WRONG OUTPUT (2 tasks):
Task 1: {"title": "Review security protocols", "assignee": "Dario", ...}
Task 2: {"title": "Coordinate security review", "assignee": "Iqra", ...}
✅ CORRECT OUTPUT (1 task):
Task 1: {
"title": "Coordinate security protocol review",
"assignee": "Iqra",
"dueDate": "2025-11-07",
"description": "Dario Amodei will personally review the security protocols for the AI model before the end of the week. Iqra Khan will coordinate the security review process."
}
KEY RULES:
1. If multiple people mentioned for same deliverable → ONE task only
2. Task title = main deliverable/outcome
3. Assignee = person doing final/critical step (reviewer, approver, coordinator, presenter)
4. Description = full context with ALL people and their roles mentioned
5. DO NOT split workflows into multiple tasks
6. Look for keywords: "and then", "before", "after", "will review", "will coordinate", "will approve"
7. IMPORTANT: Do NOT extract commitments separately - include all commitments as part of action items
For assignees (MANDATORY - always extract if possible):
- ALWAYS assign the task to someone if ANY person is mentioned doing work
- Priority order: reviewer > creator, coordinator > contributor, approver > submitter, presenter > preparer
- Look for: "X will", "assigned to X", "X is responsible", "X to review/approve/coordinate/present"
- Examples of who to assign:
* "Brian will finalize, Irfan will review" → Assign to Irfan (reviewer)
* "Dario will review, Iqra will coordinate" → Assign to Iqra (coordinator)
* "Sarah will create report" → Assign to Sarah
- If only ONE person mentioned → assign to them
- If MULTIPLE people mentioned → assign to the one doing the FINAL/CRITICAL step
For due dates (MANDATORY - always extract if possible):
- ALWAYS include dueDate if ANY time reference is mentioned
- Current date context: Meeting date is 2025-11-01 (Saturday)
- Convert relative dates to YYYY-MM-DD format:
* "next Monday" → "2025-11-03"
* "this Friday" → "2025-11-07"
* "end of week" → "2025-11-07" (Friday)
* "end of month" → "2025-11-30"
* "next week" → "2025-11-08" (7 days from meeting)
* "in 3 days" → "2025-11-04"
- If specific date mentioned: "November 10" → "2025-11-10"
- Use the LATEST date mentioned if multiple dates in the workflow
Return JSON with this structure (NOTE: commitments array should always be EMPTY):
{
"summary": "string",
"keyPoints": ["string"],
"actionItems": [{"title": "string", "description": "string", "assignee": "string", "dueDate": "YYYY-MM-DD"}],
"commitments": []
}
Transcript:
${transcript}`;
const completion = await openai.chat.completions.create({
model: LLM_MODEL_ID,
messages: [
{
role: 'system',
content:
'You are a meeting analysis assistant. When multiple people work on the same deliverable, create ONE task (not multiple). ALWAYS assign tasks to someone and ALWAYS extract due dates when time references are mentioned. Include all commitments as action items. Commitments array should always be empty. Always return valid JSON.',
},
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
temperature: OPENAI_TEMPERATURE,
});
const content = completion.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from AI API');
}
let parsedResult: AnalysisResult;
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'}`,
);
}
if (!parsedResult.summary || typeof parsedResult.summary !== 'string') {
throw new Error('Invalid AI response: missing or invalid summary');
}
if (!Array.isArray(parsedResult.keyPoints)) {
throw new Error('Invalid AI response: keyPoints must be an array');
}
if (!Array.isArray(parsedResult.actionItems)) {
throw new Error('Invalid AI response: actionItems must be an array');
}
if (!Array.isArray(parsedResult.commitments)) {
throw new Error('Invalid AI response: commitments must be an array');
}
return parsedResult;
};
export const main = async (
params: TranscriptWebhookPayload,
): Promise<object> => {
const executionLogs: string[] = [];
const log = (message: string) => {
console.log(message);
executionLogs.push(message);
};
try {
const webhookToken = params.token;
const expectedSecret = process.env.WEBHOOK_SECRET_TOKEN;
if (!expectedSecret || !webhookToken || webhookToken !== expectedSecret) {
throw new Error('Unauthorized webhook access: Invalid or missing token.');
}
const {
transcript,
meetingTitle,
meetingDate,
relatedPersonId,
participants,
} = params;
if (!transcript || typeof transcript !== 'string') {
throw new Error('Transcript is required and must be a string');
}
if (!relatedPersonId || typeof relatedPersonId !== 'string') {
throw new Error('relatedPersonId is required and must be a string');
}
const openaiApiKey = process.env.AI_PROVIDER_API_KEY;
if (!openaiApiKey) {
throw new Error('AI_PROVIDER_API_KEY environment variable is not set');
}
log('✅ Validation passed');
log(`📝 RelatedPersonId: ${relatedPersonId}`);
log('🤖 Starting transcript analysis...');
const analysis = await analyzeTranscript(transcript, openaiApiKey);
log(
`✅ Analysis complete: ${analysis.actionItems.length} action items, ${analysis.commitments.length} commitments`,
);
log('📄 Creating note in Twenty CRM...');
const note = await createNoteInTwenty(
analysis.summary,
analysis.keyPoints,
relatedPersonId,
meetingTitle,
meetingDate,
);
log(`✅ Note created: ${note.id}`);
log('📋 Creating tasks from action items...');
const actionItemTaskIds = await createTasksFromActionItems(
analysis.actionItems,
note.id,
relatedPersonId,
participants || [],
);
log(`✅ Action item tasks created: ${actionItemTaskIds.length}`);
log('📋 Creating tasks from commitments...');
const commitmentTaskIds = await createTasksFromCommitments(
analysis.commitments,
note.id,
relatedPersonId,
);
log(`✅ Commitment tasks created: ${commitmentTaskIds.length}`);
const allTaskIds = [...actionItemTaskIds, ...commitmentTaskIds];
return {
success: true,
noteId: note.id,
taskIds: allTaskIds,
summary: {
noteCreated: true,
tasksCreated: allTaskIds.length,
actionItemsProcessed: analysis.actionItems.length,
commitmentsProcessed: analysis.commitments.length,
},
executionLogs: executionLogs,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log(`❌ ERROR: ${errorMessage}`);
return {
success: false,
error: errorMessage,
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,
},
],
};
@@ -1,26 +0,0 @@
{
"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,464 +0,0 @@
# 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-fetch@npm:^2.6.4":
version: 2.6.13
resolution: "@types/node-fetch@npm:2.6.13"
dependencies:
"@types/node": "npm:*"
form-data: "npm:^4.0.4"
checksum: 10c0/6313c89f62c50bd0513a6839cdff0a06727ac5495ccbb2eeda51bb2bbbc4f3c0a76c0393a491b7610af703d3d2deb6cf60e37e59c81ceeca803ffde745dbf309
languageName: node
linkType: hard
"@types/node@npm:*":
version: 24.9.2
resolution: "@types/node@npm:24.9.2"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/7905d43f65cee72ef475fe76316e10bbf6ac5d08a7f0f6c38f2b6285d7ca3009e8fcafc8f8a1d2bf3f55889c9c278dbb203a9081fd0cf2d6d62161703924c6fa
languageName: node
linkType: hard
"@types/node@npm:^18.11.18":
version: 18.19.130
resolution: "@types/node@npm:18.19.130"
dependencies:
undici-types: "npm:~5.26.4"
checksum: 10c0/22ba2bc9f8863101a7e90a56aaeba1eb3ebdc51e847cef4a6d188967ab1acbce9b4f92251372fd0329ecb924bbf610509e122c3dfe346c04dbad04013d4ad7d0
languageName: node
linkType: hard
"@types/node@npm:^20.0.0":
version: 20.19.24
resolution: "@types/node@npm:20.19.24"
dependencies:
undici-types: "npm:~6.21.0"
checksum: 10c0/c872ce80a1e832fe035a3c94a27acb2d6e45ffa1209c0241ac6e2d405db8d6f47eea7a2509b5c2dbedae6231dafb9dbed873dd5daaebbad1f11fdaa58726ce5e
languageName: node
linkType: hard
"abort-controller@npm:^3.0.0":
version: 3.0.0
resolution: "abort-controller@npm:3.0.0"
dependencies:
event-target-shim: "npm:^5.0.0"
checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5
languageName: node
linkType: hard
"agentkeepalive@npm:^4.2.1":
version: 4.6.0
resolution: "agentkeepalive@npm:4.6.0"
dependencies:
humanize-ms: "npm:^1.2.1"
checksum: 10c0/235c182432f75046835b05f239708107138a40103deee23b6a08caee5136873709155753b394ec212e49e60e94a378189562cb01347765515cff61b692c69187
languageName: node
linkType: hard
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
languageName: node
linkType: hard
"async-generator-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-generator-function@npm:1.0.0"
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
languageName: node
linkType: hard
"asynckit@npm:^0.4.0":
version: 0.4.0
resolution: "asynckit@npm:0.4.0"
checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
languageName: node
linkType: hard
"axios@npm:^1.7.0":
version: 1.13.1
resolution: "axios@npm:1.13.1"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.4"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/de9c3c6de43d3ee1146d3afe78645f19450cac6a5d7235bef8b8e8eeb705c2e47e2d231dea99cecaec4dae1897c521118ca9413b9d474063c719c4d94c5b9adc
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
dependencies:
delayed-stream: "npm:~1.0.0"
checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
languageName: node
linkType: hard
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.3.0":
version: 1.3.0
resolution: "es-errors@npm:1.3.0"
checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85
languageName: node
linkType: hard
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.1.0":
version: 2.1.0
resolution: "es-set-tostringtag@npm:2.1.0"
dependencies:
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.6"
has-tostringtag: "npm:^1.0.2"
hasown: "npm:^2.0.2"
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
languageName: node
linkType: hard
"event-target-shim@npm:^5.0.0":
version: 5.0.1
resolution: "event-target-shim@npm:5.0.1"
checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b
languageName: node
linkType: hard
"follow-redirects@npm:^1.15.6":
version: 1.15.11
resolution: "follow-redirects@npm:1.15.11"
peerDependenciesMeta:
debug:
optional: true
checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
languageName: node
linkType: hard
"form-data-encoder@npm:1.7.2":
version: 1.7.2
resolution: "form-data-encoder@npm:1.7.2"
checksum: 10c0/56553768037b6d55d9de524f97fe70555f0e415e781cb56fc457a68263de3d40fadea2304d4beef2d40b1a851269bd7854e42c362107071892cb5238debe9464
languageName: node
linkType: hard
"form-data@npm:^4.0.4":
version: 4.0.4
resolution: "form-data@npm:4.0.4"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
languageName: node
linkType: hard
"formdata-node@npm:^4.3.2":
version: 4.4.1
resolution: "formdata-node@npm:4.4.1"
dependencies:
node-domexception: "npm:1.0.0"
web-streams-polyfill: "npm:4.0.0-beta.3"
checksum: 10c0/74151e7b228ffb33b565cec69182694ad07cc3fdd9126a8240468bb70a8ba66e97e097072b60bcb08729b24c7ce3fd3e0bd7f1f80df6f9f662b9656786e76f6a
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5
languageName: node
linkType: hard
"generator-function@npm:^2.0.0":
version: 2.0.1
resolution: "generator-function@npm:2.0.1"
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.6":
version: 1.3.1
resolution: "get-intrinsic@npm:1.3.1"
dependencies:
async-function: "npm:^1.0.0"
async-generator-function: "npm:^1.0.0"
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
generator-function: "npm:^2.0.0"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
languageName: node
linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.2":
version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2"
dependencies:
has-symbols: "npm:^1.0.3"
checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c
languageName: node
linkType: hard
"hasown@npm:^2.0.2":
version: 2.0.2
resolution: "hasown@npm:2.0.2"
dependencies:
function-bind: "npm:^1.1.2"
checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9
languageName: node
linkType: hard
"humanize-ms@npm:^1.2.1":
version: 1.2.1
resolution: "humanize-ms@npm:1.2.1"
dependencies:
ms: "npm:^2.0.0"
checksum: 10c0/f34a2c20161d02303c2807badec2f3b49cbfbbb409abd4f95a07377ae01cfe6b59e3d15ac609cffcd8f2521f0eb37b7e1091acf65da99aa2a4f1ad63c21e7e7a
languageName: node
linkType: hard
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
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"
checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
languageName: node
linkType: hard
"mime-types@npm:^2.1.12":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
mime-db: "npm:1.52.0"
checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
languageName: node
linkType: hard
"ms@npm:^2.0.0":
version: 2.1.3
resolution: "ms@npm:2.1.3"
checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48
languageName: node
linkType: hard
"node-domexception@npm:1.0.0":
version: 1.0.0
resolution: "node-domexception@npm:1.0.0"
checksum: 10c0/5e5d63cda29856402df9472335af4bb13875e1927ad3be861dc5ebde38917aecbf9ae337923777af52a48c426b70148815e890a5d72760f1b4d758cc671b1a2b
languageName: node
linkType: hard
"node-fetch@npm:^2.6.7":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
dependencies:
whatwg-url: "npm:^5.0.0"
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8
languageName: node
linkType: hard
"openai@npm:^4.67.0":
version: 4.104.0
resolution: "openai@npm:4.104.0"
dependencies:
"@types/node": "npm:^18.11.18"
"@types/node-fetch": "npm:^2.6.4"
abort-controller: "npm:^3.0.0"
agentkeepalive: "npm:^4.2.1"
form-data-encoder: "npm:1.7.2"
formdata-node: "npm:^4.3.2"
node-fetch: "npm:^2.6.7"
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
bin:
openai: bin/cli
checksum: 10c0/c4f2e837684ed96b8cec58c65a584646d667c69918f29052775e2e8c05ff5c860d8b58214a7770bc6895ca8602480420c1db6a5392dd250179eb0b91c2b19a2f
languageName: node
linkType: hard
"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
languageName: node
linkType: hard
"tr46@npm:~0.0.3":
version: 0.0.3
resolution: "tr46@npm:0.0.3"
checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11
languageName: node
linkType: hard
"typescript@npm:^5.6.0":
version: 5.9.3
resolution: "typescript@npm:5.9.3"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5
languageName: node
linkType: hard
"typescript@patch:typescript@npm%3A^5.6.0#optional!builtin<compat/typescript>":
version: 5.9.3
resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin<compat/typescript>::version=5.9.3&hash=5786d5"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430
languageName: node
linkType: hard
"undici-types@npm:~5.26.4":
version: 5.26.5
resolution: "undici-types@npm:5.26.5"
checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501
languageName: node
linkType: hard
"undici-types@npm:~6.21.0":
version: 6.21.0
resolution: "undici-types@npm:6.21.0"
checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
"web-streams-polyfill@npm:4.0.0-beta.3":
version: 4.0.0-beta.3
resolution: "web-streams-polyfill@npm:4.0.0-beta.3"
checksum: 10c0/a9596779db2766990117ed3a158e0b0e9f69b887a6d6ba0779940259e95f99dc3922e534acc3e5a117b5f5905300f527d6fbf8a9f0957faf1d8e585ce3452e8e
languageName: node
linkType: hard
"webidl-conversions@npm:^3.0.0":
version: 3.0.1
resolution: "webidl-conversions@npm:3.0.1"
checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db
languageName: node
linkType: hard
"whatwg-url@npm:^5.0.0":
version: 5.0.0
resolution: "whatwg-url@npm:5.0.0"
dependencies:
tr46: "npm:~0.0.3"
webidl-conversions: "npm:^3.0.0"
checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5
languageName: node
linkType: hard
@@ -1,32 +0,0 @@
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,23 +0,0 @@
{
"name": "rollup-engine",
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"smoke": "node --import tsx scripts/run-rollup-smoke.ts",
"setup:metadata": "node scripts/setup-company-rollup-fields.mjs"
},
"dependencies": {
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/node": "^24.7.2",
"dotenv": "^16.4.5",
"tsx": "^4.20.6"
}
}
@@ -1,143 +0,0 @@
import { applyFilters, getNestedValue, toComparableNumber } from './filtering';
import type { ChildRecord, RollupDefinition } from './types';
const roundForSum = (value: number) =>
Number.isInteger(value) ? value : Math.round(value * 100) / 100;
export const computeAggregations = (
definition: RollupDefinition,
records: ChildRecord[],
now: Date,
) => {
const baseFiltered = applyFilters(records, definition.childFilters, now);
const result: Record<
string,
number | string | null | Record<string, unknown>
> = {};
definition.aggregations.forEach((aggregation) => {
const scopedRecords = applyFilters(baseFiltered, aggregation.filters, now);
switch (aggregation.type) {
case 'COUNT':
result[aggregation.parentField] = scopedRecords.length;
break;
case 'SUM': {
if (!aggregation.childField) {
throw new Error('SUM aggregation requires childField');
}
const total = scopedRecords.reduce(
(accumulator, record) => {
const rawValue = getNestedValue(record, aggregation.childField!);
const currencyRaw =
aggregation.currencyField !== undefined
? getNestedValue(record, aggregation.currencyField)
: undefined;
const numeric =
typeof rawValue === 'number'
? rawValue
: typeof rawValue === 'string'
? Number(rawValue)
: NaN;
if (Number.isNaN(numeric)) {
return accumulator;
}
return {
amount: (accumulator.amount as number) + numeric,
currency:
typeof currencyRaw === 'string' && currencyRaw.trim().length > 0
? currencyRaw
: accumulator.currency,
};
},
{ amount: 0, currency: undefined as string | undefined },
);
result[aggregation.parentField] = {
amountMicros: Math.round(roundForSum(total.amount as number)),
currencyCode: total.currency ?? '',
};
break;
}
case 'AVG': {
if (!aggregation.childField) {
throw new Error('AVG aggregation requires childField');
}
const { total, count } = scopedRecords.reduce(
(accumulator, record) => {
const rawValue = getNestedValue(record, aggregation.childField!);
const numeric =
typeof rawValue === 'number'
? rawValue
: typeof rawValue === 'string'
? Number(rawValue)
: NaN;
if (Number.isNaN(numeric)) {
return accumulator;
}
return {
total: (accumulator.total as number) + numeric,
count: (accumulator.count as number) + 1,
};
},
{ total: 0, count: 0 },
);
result[aggregation.parentField] =
count === 0
? null
: roundForSum((total as number) / (count as number));
break;
}
case 'MAX':
case 'MIN': {
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;
for (const record of scopedRecords) {
const rawValue = getNestedValue(record, childField);
const comparable = toComparableNumber(rawValue);
if (comparable === null) continue;
if (
chosen === null ||
direction * (comparable - chosen.comparable) > 0
) {
chosen = { raw: rawValue, comparable };
}
}
if (chosen === null) {
result[aggregation.parentField] = null;
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(raw);
const parsed = Date.parse(rawString);
result[aggregation.parentField] = Number.isNaN(parsed)
? rawString
: new Date(parsed).toISOString().slice(0, 10);
}
break;
}
default:
break;
}
});
return result;
};
@@ -1,205 +0,0 @@
import { defaultRollupConfig } from './rollupConfig';
import type {
AggregationConfig,
FilterConfig,
RollupConfig,
RollupDefinition,
} from './types';
const operatorSet = new Set<FilterConfig['operator']>([
'equals',
'notEquals',
'in',
'notIn',
'gt',
'gte',
'lt',
'lte',
]);
const aggregationTypes = new Set<AggregationConfig['type']>([
'SUM',
'COUNT',
'MAX',
'MIN',
'AVG',
]);
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
export const validateRollupConfig = (config: any): RollupConfig | void => {
if (!Array.isArray(config)) {
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`,
);
}
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`);
}
if (typeof childObject !== 'string' || childObject.trim().length === 0) {
throw new Error(`Definition ${definitionIndex} missing childObject`);
}
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`,
);
}
const filtersToValidate = [
...(Array.isArray(childFilters) ? childFilters : []),
...aggregations.flatMap((aggregation, aggregationIndex) => {
if (!isObject(aggregation)) {
throw new Error(
`Aggregation ${aggregationIndex} in definition ${definitionIndex} must be an object`,
);
}
const { type, parentField, childField, filters } =
aggregation as AggregationConfig;
if (!aggregationTypes.has(type)) {
throw new Error(
`Aggregation ${aggregationIndex} in definition ${definitionIndex} has unsupported type ${type}`,
);
}
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)
) {
throw new Error(
`Aggregation ${aggregationIndex} in definition ${definitionIndex} with type ${type} requires childField`,
);
}
if (filters && !Array.isArray(filters)) {
throw new Error(
`Aggregation ${aggregationIndex} in definition ${definitionIndex} has invalid filters shape`,
);
}
return filters ?? [];
}),
];
filtersToValidate.forEach((filter, filterIndex) => {
if (!isObject(filter)) {
throw new Error(
`Filter ${filterIndex} in definition ${definitionIndex} must be an object`,
);
}
const { field, operator } = filter as FilterConfig;
if (typeof field !== 'string' || field.trim().length === 0) {
throw new Error(
`Filter ${filterIndex} in definition ${definitionIndex} missing field`,
);
}
if (!operatorSet.has(operator)) {
throw new Error(
`Filter ${filterIndex} in definition ${definitionIndex} has unsupported operator ${operator}`,
);
}
});
});
};
const collectValuesByKey = (
value: unknown,
key: string,
result: Set<string>,
): void => {
if (Array.isArray(value)) {
value.forEach((entry) => collectValuesByKey(entry, key, result));
return;
}
if (!isObject(value)) {
return;
}
Object.entries(value).forEach(([currentKey, entryValue]) => {
if (
currentKey === key &&
typeof entryValue === 'string' &&
entryValue.trim().length > 0
) {
result.add(entryValue);
return;
}
collectValuesByKey(entryValue, key, result);
});
};
export const extractRelationValues = (
params: unknown,
relationField: string,
): Set<string> => {
const values = new Set<string>();
collectValuesByKey(params, relationField, values);
return values;
};
export const resolveRollupConfig = (): RollupConfig => {
const override =
process.env.ROLLUP_ENGINE_CONFIG ??
process.env.ROLLUPS_CONFIG ??
process.env.CALCULATE_ROLLUPS_CONFIG;
if (override) {
try {
const parsed = JSON.parse(override) as unknown;
validateRollupConfig(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})`,
);
}
}
const config = defaultRollupConfig;
validateRollupConfig(config);
return config;
};
@@ -1,257 +0,0 @@
import { computeAggregations } from './aggregations';
import { buildChildRecordIndex, TwentyClient } from './client';
import { extractRelationValues, resolveRollupConfig } from './config';
import { getNestedValue } from './filtering';
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;
const sanitizePayload = (payload: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(payload).filter(([, value]) => {
if (value === undefined) {
return false;
}
if (typeof value === 'number' && !Number.isFinite(value)) {
return false;
}
return true;
}),
);
const determineFullRebuild = (params: unknown) => {
if (!isObject(params)) {
return false;
}
if (params.recalculateAll === true || params.fullRebuild === true) {
return true;
}
if (isObject(params.trigger) && params.trigger.type === 'cron') {
return true;
}
if (params.trigger === 'cron') {
return true;
}
return false;
};
const getApiCredentials = () => {
const apiKey =
process.env.TWENTY_API_KEY ??
process.env.TWENTY_API_TOKEN ??
process.env.API_KEY;
if (!apiKey) {
return null;
}
const baseUrlRaw =
process.env.TWENTY_API_BASE_URL ??
process.env.TWENTY_REST_BASE_URL ??
process.env.TWENTY_API_URL ??
'';
const baseUrl =
typeof baseUrlRaw === 'string' && baseUrlRaw.trim().length > 0
? baseUrlRaw
: 'https://app.twenty.com/rest';
return { apiKey, baseUrl };
};
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,
),
},
details: summaries,
});
export const main = async (params: unknown): Promise<any> => {
const start = Date.now();
try {
const config = resolveRollupConfig();
if (config.length === 0) {
return { status: 'noop', reason: 'No rollup definitions configured' };
}
const fullRebuild = determineFullRebuild(params);
const relationCache = new Map<string, Set<string>>();
config.forEach((definition) => {
if (!relationCache.has(definition.relationField)) {
relationCache.set(
definition.relationField,
extractRelationValues(params, definition.relationField),
);
}
});
const credentials = getApiCredentials();
if (!credentials) {
console.warn(
'[rollup] skipping execution because TWENTY_API_KEY is not set',
);
return { status: 'noop', reason: 'TWENTY_API_KEY not configured' };
}
const { apiKey, baseUrl } = credentials;
const client = new TwentyClient(apiKey, baseUrl);
const now = new Date();
const summaries: ExecutionSummaryItem[] = [];
for (const definition of config) {
const targetIds = fullRebuild
? undefined
: relationCache.get(definition.relationField);
if (!fullRebuild && (!targetIds || targetIds.size === 0)) {
summaries.push({
parentObject: definition.parentObject,
processed: 0,
updated: 0,
relationField: definition.relationField,
mode: 'targeted',
skipped: `No ${definition.relationField} values found in payload`,
});
continue;
}
const childIndex = await buildChildRecordIndex(
definition,
client,
targetIds,
);
const updates: Array<{
id: string;
payload: Record<string, unknown>;
context: { relationId: string };
}> = [];
childIndex.forEach((records, parentId) => {
const recordIds = records
.map((record) => getNestedValue(record, 'id'))
.filter((value): value is string => typeof value === 'string');
console.info(
`[rollup] relation ${parentId} includes ${recordIds.length} ${definition.childObject}(s): ${recordIds.join(', ')}`,
);
const aggregates = computeAggregations(definition, records, now);
const payload = sanitizePayload(aggregates);
if (Object.keys(payload).length === 0) {
return;
}
console.info(
`[rollup] computed aggregates for ${definition.parentObject} ${parentId}: ${JSON.stringify(payload)}`,
);
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,
);
updatedCount += 1;
console.info(
`[rollup] updated ${definition.parentObject} ${update.id} (relation ${update.context.relationId})`,
);
} catch (error) {
const reason =
error instanceof Error
? error.message || error.toString()
: typeof error === 'string'
? error
: 'Unknown error';
console.warn(
`[rollup] failed to update ${definition.parentObject} ${update.id} (relation ${update.context.relationId}): ${reason}`,
);
}
}
summaries.push({
parentObject: definition.parentObject,
processed: childIndex.size,
updated: updatedCount,
relationField: definition.relationField,
mode: fullRebuild ? 'full-rebuild' : 'targeted',
});
}
const totalProcessed = summaries.reduce(
(accumulator, item) => accumulator + item.processed,
0,
);
if (!fullRebuild && totalProcessed === 0) {
return {
status: 'noop',
reason: 'No matching relation ids found in payload',
};
}
const duration = Date.now() - start;
console.info(
`[rollup] completed mode=${fullRebuild ? 'full-rebuild' : 'targeted'} processed=${totalProcessed} durationMs=${duration}`,
);
return formatSummary(summaries, duration);
} catch (error) {
const serializedError =
error instanceof Error
? `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`
: JSON.stringify(error);
console.error('[rollup] execution failed', serializedError);
return {
status: 'error',
message:
error instanceof Error
? error.message || 'Unknown error'
: typeof error === 'string'
? error
: '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,
},
],
};
@@ -1,27 +0,0 @@
{
"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 it is too large Load Diff
@@ -1,37 +0,0 @@
# Stripe synchronizer
Synchronizes customers from Stripe to Twenty
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
- Stripe secret API key - available in Stripe workbench
## Setup
1. Synchronize app
```bash
twenty auth login
cd stripe-synchronizer
twenty app sync
```
2. Go to Stripe > Workbench > Webhooks and add webhook:
- events: customer.subscription.created and customer.subscription.updated
- webhook endpoint
- destination: `{TWENTY_URL}/s/webhook/stripe`, e.g. https://workspace.twenty.com/s/webhook/stripe
3. Go to Twenty > Settings > Integrations > Stripe synchronizer > Settings and add values
## Flow
1. Retrieve Stripe webhook
2. Check if it's either subscription created or updated, if not, exit
3. Read customer ID, sub status and quantity from webhook
4. Read customer data from Stripe API, if business name is empty, exit
5. Check if customer company exists in Twenty, if not, create it
6. Check if related person exists in Twenty, if not, create it and link to company
## Notes
- app synchronizes only new customers, those created before start of app won't be synchronized unless they're updated
- customers will be added to Twenty People object only if their name and email are filled with data, otherwise app will throw an error
## Todo
- add validation of signature key from Stripe to ensure that incoming request is valid
(possible once request headers are exposed to serverless functions)
@@ -1,27 +0,0 @@
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',
icon: "IconMoneyBag",
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'b0d9569b-da3e-4dad-b7b1-36c96f0598b9',
isSecret: true,
description: 'Required to send requests to Twenty',
},
TWENTY_API_URL: {
universalIdentifier: 'fa50e016-e045-497a-9cdf-0949e7ef9f7a',
isSecret: false,
description: 'Optional, defaults to cloud API URL',
},
STRIPE_API_KEY: {
universalIdentifier: '807d67d6-f720-49c4-a93e-ef16cf4fe919',
isSecret: true,
description: 'Required to send request to Stripe',
},
},
};
export default config;
@@ -1,18 +0,0 @@
{
"name": "stripe-synchronizer",
"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.13.2",
"twenty-sdk": "0.0.4"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,500 +0,0 @@
import axios from 'axios';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
import { type stripeCustomer, type stripeEvent, type stripeStatus, type twentyObject } from './types';
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
const TWENTY_API_URL: string =
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
? `${process.env.TWENTY_API_URL}/rest`
: 'https://api.twenty.com/rest';
const STRIPE_API_KEY: string = process.env.STRIPE_API_KEY ?? '';
const STRIPE_API_URL: string = 'https://api.stripe.com/v1/customers';
const getTwentyObjectData = async (
objectSingularName: string,
): Promise<twentyObject | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/metadata/objects`,
};
try {
const response = await axios.request(options);
if (response.status === 200) {
const companyObject = response.data.data.objects.find(
(object: twentyObject) => object.nameSingular === objectSingularName,
);
return (companyObject as twentyObject) ?? ({} as twentyObject);
}
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const createFields = async (objectId: string, fieldName: string) => {
const data =
fieldName === 'seats'
? {
type: 'NUMBER',
objectMetadataId: objectId,
name: 'seats',
label: 'Seats',
icon: 'IconMan',
}
: {
type: 'SELECT',
objectMetadataId: objectId,
name: 'subStatus',
label: 'Sub Status',
icon: 'IconStatusChange',
options: [
{
color: 'iris',
label: 'Incomplete',
value: 'INCOMPLETE',
position: 1,
},
{
color: 'sky',
label: 'Incomplete (expired)',
value: 'INCOMPLETE_EXPIRED',
position: 2,
},
{
color: 'amber',
label: 'Trialing',
value: 'TRIALING',
position: 3,
},
{
color: 'green',
label: 'Active',
value: 'ACTIVE',
position: 4,
},
{
color: 'orange',
label: 'Past due',
value: 'PAST_DUE',
position: 5,
},
{
color: 'brown',
label: 'Canceled',
value: 'CANCELED',
position: 6,
},
{
color: 'red',
label: 'Unpaid',
value: 'UNPAID',
position: 7,
},
{
color: 'gray',
label: 'Paused',
value: 'PAUSED',
position: 8,
},
],
};
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/metadata/fields`,
data: data,
};
try {
const response = await axios(options);
return response.status === 201;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const getStripeCustomerData = async (
customerID: string,
): Promise<stripeCustomer | undefined> => {
const options = {
method: 'GET',
url: `${STRIPE_API_URL}/${customerID}`,
auth: {
username: STRIPE_API_KEY,
password: '',
},
};
try {
const response = await axios(options);
return response.status === 200
? ({
name: response.data.name,
businessName: response.data.business_name,
email: response.data.email,
} as stripeCustomer)
: ({} as stripeCustomer);
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfCompanyExistsInTwenty = async (
name: string | undefined,
): Promise<string | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/companies?filter=name%5Beq%5D%3A%22${name}%22`,
};
try {
const response = await axios(options);
return response.status === 200 &&
response.data.data.companies[0].id !== undefined
? (response.data.data.companies[0].id as string)
: '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateTwentyCompany = async (
companyId: string | undefined,
seats: number | null,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/companies/${companyId}`,
data: {
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const createTwentyCompany = async (
customerName: string | undefined,
seats: number | null,
subStatus: string,
): Promise<string | undefined> => {
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/companies`,
data: {
name: customerName,
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios(options);
return response.status === 201 ? (response.data.data.id as string) : '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfStripePersonExistsInTwenty = async (email: string | null) => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people?filter=emails.primaryEmail%5Beq%5D%3A%22${email}%22`, // mail is unique by default so there can be only 1 person with given mail
};
try {
const response = await axios.request(options);
return response.status === 200 &&
response.data.data.people[0].id !== undefined
? (response.data.data.people[0].id as string)
: '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const addTwentyPerson = async (
firstName: string,
lastName: string,
email: string,
companyId: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people`,
data: {
firstName: firstName,
lastName: lastName,
emails: { primaryEmail: email },
companyId: companyId,
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios.request(options);
return response.status === 201;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateTwentyPerson = async (
id: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people/${id}`,
data: {
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
export const main = async (
params: Record<string, any>,
): Promise<object | undefined> => {
if (TWENTY_API_KEY === '' || STRIPE_API_KEY === '') {
throw new Error('Missing variables');
}
try {
// TODO: add validation of signature key from Stripe (not possible at the moment as headers aren't accessible in serverless functions)
const stripe = params as stripeEvent;
const allowed_types: string[] = [
'customer.subscription.created',
'customer.subscription.updated',
];
if (!allowed_types.includes(stripe.type)) {
throw new Error('Wrong type of webhook');
}
const stripeCustomer: stripeCustomer | undefined =
await getStripeCustomerData(stripe.data.object.customer);
if (
stripeCustomer?.businessName === undefined ||
stripeCustomer?.businessName === ''
) {
console.warn('Set customer business name in Stripe');
return {};
}
const companyObject = await getTwentyObjectData('company');
if (
companyObject?.fields.find((field) => field.name === 'seats') ===
undefined
) {
const seatsFieldCreated: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'seats')
: false;
if (!seatsFieldCreated) {
throw new Error('Seats field creation in Company object failed');
} else {
console.info('Seats field creation in Company object succeeded');
}
}
if (
companyObject?.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const subStatusFieldCreated: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'subStatus')
: false;
if (!subStatusFieldCreated) {
throw new Error('Sub status field creation in Company object failed');
} else {
console.info('Sub status field creation in Company object succeeded');
}
}
const personObject = await getTwentyObjectData('person');
if (
personObject?.fields.find((field) => field.name === 'seats') === undefined
) {
const seatsFieldCreated: boolean | undefined = personObject?.id
? await createFields(personObject?.id, 'seats')
: false;
if (!seatsFieldCreated) {
throw new Error('Seats field creation in People object failed');
} else {
console.info('Seats field creation in People object succeeded');
}
}
if (
personObject?.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const subStatusFieldCreated: boolean | undefined = personObject?.id
? await createFields(personObject?.id, 'subStatus')
: false;
if (!subStatusFieldCreated) {
throw new Error('Sub status field creation in People object failed');
} else {
console.info('Sub status field creation in People object succeeded');
}
}
const twentyCompanyId: string | undefined =
await checkIfCompanyExistsInTwenty(stripeCustomer?.businessName);
const seats: number =
stripe.data.object.quantity ??
stripe.data.object.items.data.reduce(
(acc, item) => acc + item.quantity,
0,
); // we don't know if subscription has only 1 item (product) or more
let updatedTwentyCompanyId: string | undefined;
if (twentyCompanyId === '') {
const twentyCompanyCreated: string | undefined =
await createTwentyCompany(
stripeCustomer?.businessName,
seats,
stripe.data.object.status.toUpperCase(),
);
if (twentyCompanyCreated === '') {
throw new Error('Creation of Stripe customer in Twenty failed');
} else {
console.log('Creation of Stripe customer in Twenty succeeded');
updatedTwentyCompanyId = twentyCompanyCreated;
}
} else {
const twentyCompanyUpdated: boolean | undefined =
await updateTwentyCompany(
twentyCompanyId,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!twentyCompanyUpdated) {
throw new Error('Update of Stripe customer in Twenty failed');
} else {
console.log('Update of Stripe customer in Twenty succeeded');
updatedTwentyCompanyId = twentyCompanyId;
}
}
if (updatedTwentyCompanyId === undefined || updatedTwentyCompanyId === '') {
throw new Error('TwentyCompanyId not found');
} else {
const stripeCustomerInTwenty: string | undefined =
await checkIfStripePersonExistsInTwenty(stripeCustomer.email);
if (stripeCustomerInTwenty === '') {
if (!stripeCustomer.name) {
throw new Error('Missing Stripe customer first or last name');
}
if (!stripeCustomer.email) {
throw new Error('Missing Stripe customer email');
}
const firstName: string = stripeCustomer.name?.split(' ')[0];
const lastName: string = stripeCustomer.name?.split(' ')[1];
const addedStripePersonToTwenty: boolean | undefined =
await addTwentyPerson(
firstName,
lastName,
stripeCustomer.email,
updatedTwentyCompanyId,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!addedStripePersonToTwenty) {
throw new Error('Adding Stripe person to Twenty failed');
} else {
console.log('Stripe person was added to Twenty');
}
} else if (stripeCustomerInTwenty !== undefined) {
const updatedStripePersonInTwenty: boolean | undefined =
await updateTwentyPerson(
stripeCustomerInTwenty,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!updatedStripePersonInTwenty) {
throw new Error('Update of Stripe person in Twenty failed');
} else {
console.log('Update of Stripe person in Twenty succeeded');
}
} else {
throw new Error('Twenty not found');
}
}
return {};
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.message);
return {};
}
console.error(error);
return {};
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'cd15a738-18a5-406e-8b83-959dc52ebe14',
name: 'stripe',
triggers: [
{
universalIdentifier: '55f58e19-d832-43c4-9f8b-3f29fc05c162',
type: 'route',
path: '/webhook/stripe',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};
@@ -1,37 +0,0 @@
export type stripeStatus = 'INCOMPLETE' | 'INCOMPLETE_EXPIRED' | 'TRIALING' | 'ACTIVE' | 'PAST_DUE' | 'CANCELED' | 'UNPAID' | 'PAUSED';
type stripeItem = {
quantity: number;
};
type stripeItemsData = {
data: stripeItem[];
};
type stripeEventObject = {
customer: string;
items: stripeItemsData;
status: stripeStatus;
quantity: number | null;
};
type stripeEventData = {
object: stripeEventObject;
};
export type stripeEvent = {
data: stripeEventData;
type: string;
};
export type stripeCustomer = {
businessName?: string;
name: string | null;
email: string | null;
};
export type twentyObject = {
id: string;
nameSingular: string;
fields: Record<string, any>[];
};
@@ -1,27 +0,0 @@
{
"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"
]
}

Some files were not shown because too many files have changed in this diff Show More