Compare commits

..

10 Commits

Author SHA1 Message Date
neo773 f10395770c change limit 2026-04-23 14:10:38 +05:30
neo773 2e163d81bb Merge branch 'workspace-export-optimize' of https://github.com/twentyhq/twenty into workspace-export-optimize 2026-04-23 14:09:56 +05:30
neo773 d7b0975e9e add test 2026-04-23 13:29:04 +05:30
neo773 f48e603485 wip 2026-04-23 13:29:01 +05:30
neo773 180d3e1c4a wip 2026-04-23 13:28:57 +05:30
neo773 ceadd755ca optimize workspace export 2026-04-23 13:28:55 +05:30
neo773 07baf95126 add test 2026-04-23 01:10:53 +05:30
neo773 0b9641dd06 wip 2026-04-23 00:55:20 +05:30
neo773 96abbbf99c wip 2026-04-23 00:51:54 +05:30
neo773 dd8c4f1dc3 optimize workspace export 2026-04-22 23:53:35 +05:30
3063 changed files with 106333 additions and 123755 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"env": {}
},
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["@playwright/mcp@latest", "--no-sandbox", "--headless"],
"env": {}
},
"context7": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {}
}
}
}
+223
View File
@@ -0,0 +1,223 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Twenty is an open-source CRM built with modern technologies in a monorepo structure. The codebase is organized as an Nx workspace with multiple packages.
## Key Commands
### Development
```bash
# Start development environment (frontend + backend + worker)
yarn start
# Individual package development
npx nx start twenty-front # Start frontend dev server
npx nx start twenty-server # Start backend server
npx nx run twenty-server:worker # Start background worker
```
### Testing
```bash
# Preferred: run a single test file (fast)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Run all tests for a package
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# To run an indivual test or a pattern of tests, use the following command:
cd packages/{workspace} && npx jest "pattern or filename"
# Storybook
npx nx storybook:build twenty-front
npx nx storybook:test twenty-front
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
```
### Code Quality
```bash
# Linting (diff with main - fastest, always prefer this)
npx nx lint:diff-with-main twenty-front
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
# Linting (full project - slower, use only when needed)
npx nx lint twenty-front
npx nx lint twenty-server
# Type checking
npx nx typecheck twenty-front
npx nx typecheck twenty-server
# Format code
npx nx fmt twenty-front
npx nx fmt twenty-server
```
### Build
```bash
# Build packages (twenty-shared must be built first)
npx nx build twenty-shared
npx nx build twenty-front
npx nx build twenty-server
```
### Database Operations
```bash
# Database management
npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run instance commands (fast only)
# Generate an instance command (fast or slow)
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
```
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
### Package Structure
```
packages/
├── twenty-front/ # React frontend application
├── twenty-server/ # NestJS backend API
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website/ # Next.js documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
```
### Key Development Principles
- **Functional components only** (no class components)
- **Named exports only** (no default exports)
- **Types over interfaces** (except when extending third-party interfaces)
- **String literals over enums** (except for GraphQL enums)
- **No 'any' type allowed** — strict TypeScript enforced
- **Event handlers preferred over useEffect** for state updates
- **Props down, events up** — unidirectional data flow
- **Composition over inheritance**
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
### Naming Conventions
- **Variables/functions**: camelCase
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
- **TypeScript generics**: descriptive names (`TData` not `T`)
### File Structure
- Components under 300 lines, services under 500 lines
- Components in their own directories with tests and stories
- Use `index.ts` barrel exports for clean imports
- Import order: external libraries first, then internal (`@/`), then relative
### Comments
- Use short-form comments (`//`), not JSDoc blocks
- Explain WHY (business logic), not WHAT
- Do not comment obvious code
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
### Backend Architecture
- **NestJS modules** for feature organization
- **TypeORM** for database ORM with PostgreSQL
- **GraphQL** API with code-first approach
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database & Upgrade Commands
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **ClickHouse** for analytics (when enabled)
- When changing entity files, generate an **instance command** (`database:migrate:generate --name <name> --type <fast|slow>`)
- **Fast** instance commands handle schema changes; **slow** ones add a `runDataMigration` step for data backfills
- **Workspace commands** iterate over all active/suspended workspaces for per-workspace upgrades
- Commands use `@RegisteredInstanceCommand` and `@RegisteredWorkspaceCommand` decorators for automatic discovery
- Include both `up` and `down` logic in instance commands
- Never delete or rewrite committed instance command `up`/`down` logic
- See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation
### Utility Helpers
Use existing helpers from `twenty-shared` instead of manual type guards:
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
## Development Workflow
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
### Before Making Changes
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
2. Test changes with relevant test suites (prefer single-file test runs)
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
4. Check that GraphQL schema changes are backward compatible
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern)
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
### Testing Strategy
- **Test behavior, not implementation** — focus on user perspective
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
- Query by user-visible elements (text, roles, labels) over test IDs
- Use `@testing-library/user-event` for realistic interactions
- Descriptive test names: "should [behavior] when [condition]"
- Clear mocks between tests with `jest.clearAllMocks()`
## Dev Environment Setup
All dev environments (Claude Code web, Cursor, local) use one script:
```bash
bash packages/twenty-utils/setup-dev-env.sh
```
This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, and copies `.env` files. Idempotent — safe to run multiple times.
- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`)
- `--down` — stop services
- `--reset` — wipe data and restart fresh
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
**Note:** CI workflows (GitHub Actions) manage services via Actions service containers and run setup steps individually — they don't use this script.
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
- `tsconfig.base.json` - Base TypeScript configuration
- `package.json` - Root package with workspace definitions
- `.cursor/rules/` - Detailed development guidelines and best practices
+31 -32
View File
@@ -106,35 +106,34 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
### 2. Create File Structure
**Create changelog file:**
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
- 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-new/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website-new/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website-new/public/images/releases/2.0/`
- 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-new/public/images/releases/{MINOR_VERSION}
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
### 3. Move Illustration Files
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
**Naming Convention:** `{VERSION}-descriptive-name.webp`
**Naming Convention:** `{VERSION}-descriptive-name.png`
Examples:
- `1.9.0-feature-name.webp`
- `1.9.0-another-feature.webp`
- `1.9.0-feature-name.png`
- `1.9.0-another-feature.png`
```bash
# Move and rename source files, then convert to webp if needed
cp ~/Downloads/🆕/source-file.png packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website-new && node scripts/convert-png-to-webp.mjs
# 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)
@@ -159,19 +158,19 @@ Date: {YYYY-MM-DD}
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.webp)
![](/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.webp)
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.png)
# Feature 3 Name
Description of the third feature.
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-3.webp)
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-3.png)
```
**Style Guidelines:**
@@ -183,7 +182,7 @@ Description of the third feature.
- **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-new/src/content/releases/` for examples
- 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
@@ -191,10 +190,10 @@ Description of the third feature.
Open the changelog file for review:
```bash
# Open in Cursor
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
# Open image folder to verify illustrations
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
Review checklist:
@@ -222,8 +221,8 @@ I've created the changelog for version {VERSION}. Here's the content for your re
[Show full MDX content]
Images moved to:
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
- 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.
```
@@ -242,8 +241,8 @@ Possible user responses:
git status
# Add files
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
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"
@@ -266,7 +265,7 @@ This release includes:
- Feature 2
- Feature 3
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
Release date: {DATE}" \
--base main \
--head {VERSION}
@@ -280,21 +279,21 @@ Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
- **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-new/src/content/releases/`
- **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-new/public/images/releases/`
- **Location**: `packages/twenty-website/public/images/releases/`
### Image Files
- **Format**: `{VERSION}-descriptive-name.webp`
- **Format**: `{VERSION}-descriptive-name.png`
- **Convention**: Kebab-case descriptive names
- **Examples**:
- `1.8.0-workflow-iterator.webp`
- `1.8.0-bulk-select.webp`
- `1.9.0-new-feature.webp`
- `1.8.0-workflow-iterator.png`
- `1.8.0-bulk-select.png`
- `1.9.0-new-feature.png`
## Quick Reference Template
@@ -311,8 +310,8 @@ Features to document:
3. ___________________________
Branch name: {VERSION}
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
```
## Tips
-11
View File
@@ -7,17 +7,6 @@ inputs:
runs:
using: 'composite'
steps:
- name: Free disk space for install
if: runner.os == 'Linux'
shell: bash
run: |
# Default GitHub images ship large SDKs this repo does not use; removing
# them avoids ENOSPC when restoring or linking a full Yarn node_modules.
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
df -h
- name: Cache primary key builder
id: globals
shell: bash
+13 -12
View File
@@ -4,19 +4,20 @@
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
preserve_hierarchy: true
base_path: ..
"preserve_hierarchy": true
"base_path": ".."
files: [
{
#
# Source files filter - PO files for Lingui
#
"source": "**/en.po",
files:
#
# Source files filter - PO files for Lingui
#
- source: packages/twenty-front/src/locales/en.po
#
# Translation files path
#
translation: '%original_path%/%locale%.po'
- source: packages/twenty-server/src/engine/core-modules/i18n/locales/en.po
translation: '%original_path%/%locale%.po'
- source: packages/twenty-emails/src/locales/en.po
translation: '%original_path%/%locale%.po'
"translation": "%original_path%/%locale%.po",
}
]
-23
View File
@@ -1,23 +0,0 @@
#
# Crowdin CLI configuration for Website translations (twenty-website-new)
# Project ID: 4
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
project_id: 4
preserve_hierarchy: true
base_url: 'https://twenty.api.crowdin.com'
base_path: ..
languages_mapping:
locale:
fr: fr-FR
files:
#
# Source file - PO file for Lingui
#
- source: packages/twenty-website-new/src/locales/en.po
#
# Translation files path
#
translation: '%original_path%/%locale%.po'
-78
View File
@@ -78,83 +78,6 @@ jobs:
tag: scope:backend
tasks: lint,typecheck
server-previous-version-upgrade-mutation-guard:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Get changed upgrade-version-command files
id: changed-files
uses: tj-actions/changed-files@v45
with:
files: |
packages/twenty-server/src/database/commands/upgrade-version-command/**
- name: Check upgrade version commands are in current version only
if: >
steps.changed-files.outputs.any_changed == 'true' &&
!contains(github.event.pull_request.labels.*.name, 'ci:allow-previous-version-upgrade-mutation')
run: |
VERSION_CONSTANT_FILE="packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts"
CURRENT_VERSION=$(sed -n "s/.*TWENTY_CURRENT_VERSION = '\([0-9.]*\)'.*/\1/p" "$VERSION_CONSTANT_FILE")
if [ -z "$CURRENT_VERSION" ]; then
echo "::error::Could not extract TWENTY_CURRENT_VERSION from $VERSION_CONSTANT_FILE"
exit 1
fi
CURRENT_DIR=$(echo "$CURRENT_VERSION" | sed -E 's/^([0-9]+)\.([0-9]+)\..*/\1-\2/')
echo "Current version: $CURRENT_VERSION (directory: $CURRENT_DIR)"
ADDED_OFFENDERS=""
MODIFIED_OFFENDERS=""
check_files() {
local category="$1"
shift
for file in "$@"; do
VERSION_DIR=$(echo "$file" | sed -n 's|.*upgrade-version-command/\([0-9]*-[0-9]*\)/.*|\1|p')
if [ -n "$VERSION_DIR" ] && [ "$VERSION_DIR" != "$CURRENT_DIR" ]; then
if [ "$category" = "added" ]; then
ADDED_OFFENDERS="$ADDED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
else
MODIFIED_OFFENDERS="$MODIFIED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
fi
fi
done
}
check_files "added" ${{ steps.changed-files.outputs.added_files }}
check_files "modified" ${{ steps.changed-files.outputs.modified_files }}
if [ -n "$ADDED_OFFENDERS" ] || [ -n "$MODIFIED_OFFENDERS" ]; then
echo "This PR touches upgrade command files outside the current version directory ($CURRENT_DIR / $CURRENT_VERSION)."
if [ -n "$ADDED_OFFENDERS" ]; then
echo ""
echo "New files added to non-current version directories:"
echo -e "$ADDED_OFFENDERS"
fi
if [ -n "$MODIFIED_OFFENDERS" ]; then
echo ""
echo "Existing files modified in non-current version directories:"
echo -e "$MODIFIED_OFFENDERS"
fi
echo ""
echo "If this is intentional, add the label 'ci:allow-previous-version-upgrade-mutation' to this PR and re-run CI."
echo "Otherwise, please move your changes to the current version directory ($CURRENT_DIR)."
echo "::error::Upgrade commands were added or modified in non-current version directories."
exit 1
fi
server-validation:
needs: server-build
timeout-minutes: 30
@@ -388,7 +311,6 @@ jobs:
changed-files-check,
server-build,
server-lint-typecheck,
server-previous-version-upgrade-mutation-guard,
server-validation,
server-test,
server-integration-test,
+38 -25
View File
@@ -1,12 +1,12 @@
name: CI Website
permissions:
contents: read
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
@@ -18,40 +18,53 @@ jobs:
with:
files: |
package.json
yarn.lock
packages/twenty-website-new/**
packages/twenty-shared/**
website-task:
packages/twenty-website/**
website-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
timeout-minutes: 10
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=6144'
strategy:
matrix:
task: [lint, typecheck, test]
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
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
- uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:website
tasks: ${{ matrix.task }}
- name: Server / Create DB
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
- name: Website / Run migrations
run: npx nx database:migrate twenty-website
env:
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
- name: Website / Build Website
run: npx nx build twenty-website
env:
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
KEYSTATIC_GITHUB_CLIENT_ID: xxx
KEYSTATIC_GITHUB_CLIENT_SECRET: xxx
KEYSTATIC_SECRET: xxx
NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG: xxx
ci-website-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, website-task]
needs: [changed-files-check, website-build]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+2
View File
@@ -74,6 +74,8 @@ 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'
-135
View File
@@ -1,135 +0,0 @@
# Pull down website translations from Crowdin every two hours or when triggered manually.
# When force_pull input is true, translations will be pulled regardless of compilation status.
name: 'Pull website 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 compilation status'
required: false
type: boolean
default: false
workflow_call:
inputs:
force_pull:
description: 'Force pull translations regardless of compilation status'
required: false
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
pull_website_translations:
name: Pull website translations
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.head_ref || github.ref_name }}
- name: Setup website i18n branch
run: |
git fetch origin i18n-website || true
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
# Strict mode fails if there are missing website translations.
- name: Compile website translations
id: compile_translations_strict
run: npx nx run twenty-website-new:lingui:compile --strict
continue-on-error: true
- name: Stash any changes before pulling translations
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add .
git stash
- name: Pull website translations from Crowdin
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
source: 'packages/twenty-website-new/src/locales/en.po'
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
export_only_approved: false
localization_branch_name: i18n-website
base_url: 'https://twenty.api.crowdin.com'
auto_approve_imported: false
import_eq_suggestions: false
download_sources: false
push_sources: false
skip_untranslated_strings: false
skip_untranslated_files: false
push_translations: false
create_pull_request: false
skip_ref_checkout: true
dryrun_action: false
config: '.github/crowdin-website.yml'
env:
GITHUB_TOKEN: ${{ github.token }}
# Website translations project
CROWDIN_PROJECT_ID: '4'
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
run: sudo chown -R runner:docker .
- name: Compile website translations
id: compile_translations
run: |
npx nx run twenty-website-new:lingui:compile
git status
git add packages/twenty-website-new/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Push changes
if: steps.compile_translations.outputs.changes_detected == 'true'
run: git push origin HEAD:i18n-website
- name: Create pull request
if: steps.compile_translations.outputs.changes_detected == 'true'
run: |
if git diff --name-only origin/main..HEAD | grep -q .; then
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
else
echo "No file differences between branches, skipping PR creation"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-111
View File
@@ -1,111 +0,0 @@
name: 'Push website translations to Crowdin'
permissions:
contents: write
pull-requests: write
on:
workflow_dispatch:
workflow_call:
push:
branches: ['main']
paths:
- 'packages/twenty-website-new/**'
- '.github/crowdin-website.yml'
- '.github/workflows/website-i18n-push.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
extract_website_translations:
name: Extract and upload website translations
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: main
- name: Setup website i18n branch
run: |
git fetch origin i18n-website || true
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Extract website translations
run: npx nx run twenty-website-new:lingui:extract
- name: Check and commit extracted files
id: check_extract_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website-new/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: extract website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Compile website translations
run: npx nx run twenty-website-new:lingui:compile
- name: Check and commit compiled files
id: check_compile_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website-new/src/locales/generated
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Push changes and create remote branch if needed
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
run: git push origin HEAD:i18n-website
- name: Upload missing website translations
if: steps.check_extract_changes.outputs.changes_detected == 'true'
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
download_translations: false
localization_branch_name: i18n-website
base_url: 'https://twenty.api.crowdin.com'
config: '.github/crowdin-website.yml'
env:
# Website translations project
CROWDIN_PROJECT_ID: '4'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Create a pull request
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
run: |
if git diff --name-only origin/main..HEAD | grep -q .; then
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
else
echo "No file differences between branches, skipping PR creation"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+1 -2
View File
@@ -110,8 +110,7 @@ packages/
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website-new/ # Next.js marketing website
├── twenty-docs/ # Documentation website
├── twenty-website/ # Next.js documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
```
+43 -43
View File
@@ -1,19 +1,19 @@
<p align="center">
<a href="https://www.twenty.com">
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website-new/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website-new/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
<p align="center">
<a href="https://www.twenty.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.png" alt="Twenty banner" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Twenty banner" />
</picture>
</a>
</p>
@@ -24,17 +24,17 @@
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
<a href="https://twenty.com/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<a href="https://twenty.com/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<br />
# Installation
### <img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
Scaffold a new app with the Twenty CLI:
@@ -68,7 +68,7 @@ npx twenty deploy
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
### <img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
@@ -79,61 +79,61 @@ Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
<table align="center">
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.png" alt="Create your apps" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" alt="Create your apps" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.png" alt="Stay on top with version control" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.png" alt="Stay on top with version control" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website-new/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.png" alt="All the tools you need to build anything" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" alt="All the tools you need to build anything" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.png" alt="Customize your layouts" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.png" alt="Customize your layouts" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png" alt="AI agents and chats" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" alt="AI agents and chats" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png" alt="Plus all the tools of a good CRM" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" alt="Plus all the tools of a good CRM" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
</td>
</tr>
</table>
@@ -142,23 +142,23 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Stack
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website-new/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website-new/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website-new/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.png" height="28" alt="Crowdin" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="28" alt="Crowdin" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
@@ -166,4 +166,4 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Join the Community
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website-new/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website-new/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website-new/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
+155 -4
View File
@@ -1,20 +1,172 @@
{
"private": true,
"dependencies": {
"@apollo/client": "^4.0.0",
"@floating-ui/react": "^0.24.3",
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
"@radix-ui/colors": "^3.0.0",
"@sniptt/guards": "^0.2.0",
"@tabler/icons-react": "^3.31.0",
"@wyw-in-js/babel-preset": "^1.0.6",
"@wyw-in-js/vite": "^0.7.0",
"archiver": "^7.0.1",
"danger-plugin-todos": "^1.3.1",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
"deep-equal": "^2.2.2",
"file-type": "16.5.4",
"framer-motion": "^11.18.0",
"fuse.js": "^7.1.0",
"googleapis": "105",
"hex-rgb": "^5.0.0",
"immer": "^10.1.1",
"jotai": "^2.17.1",
"libphonenumber-js": "^1.10.26",
"lodash.camelcase": "^4.3.0",
"lodash.chunk": "^4.2.0",
"lodash.compact": "^3.0.1",
"lodash.escaperegexp": "^4.1.2",
"lodash.groupby": "^4.6.0",
"lodash.identity": "^3.0.0",
"lodash.isempty": "^4.4.0",
"lodash.isequal": "^4.5.0",
"lodash.isobject": "^3.0.2",
"lodash.kebabcase": "^4.1.1",
"lodash.mapvalues": "^4.6.0",
"lodash.merge": "^4.6.2",
"lodash.omit": "^4.5.0",
"lodash.pickby": "^4.6.0",
"lodash.snakecase": "^4.1.1",
"lodash.upperfirst": "^4.3.1",
"microdiff": "^1.3.2",
"next-with-linaria": "^1.3.0",
"planer": "^1.2.0",
"pluralize": "^8.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.30.3",
"react-tooltip": "^5.13.1",
"remark-gfm": "^4.0.1",
"rxjs": "^7.2.0",
"semver": "^7.5.4",
"slash": "^5.1.0",
"temporal-polyfill": "^0.3.0",
"ts-key-enum": "^2.0.12",
"tslib": "^2.8.1",
"type-fest": "4.10.1",
"typescript": "5.9.2",
"uuid": "^9.0.0",
"vite-tsconfig-paths": "^4.2.1",
"xlsx-ugnis": "^0.19.3",
"zod": "^4.1.11"
},
"devDependencies": {
"@babel/core": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.24.6",
"@chromatic-com/storybook": "^4.1.3",
"@graphql-codegen/cli": "^3.3.1",
"@graphql-codegen/client-preset": "^4.1.0",
"@graphql-codegen/typescript": "^3.0.4",
"@graphql-codegen/typescript-operations": "^3.0.4",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@nx/jest": "22.5.4",
"@nx/js": "22.5.4",
"@nx/react": "22.5.4",
"@nx/storybook": "22.5.4",
"@nx/vite": "22.5.4",
"@nx/web": "22.5.4",
"@oxlint/plugins": "^1.51.0",
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-docs": "^10.3.3",
"@storybook/addon-links": "^10.3.3",
"@storybook/addon-vitest": "^10.3.3",
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.3.3",
"@storybook/test-runner": "^0.24.2",
"@swc-node/register": "^1.11.1",
"@swc/cli": "^0.7.10",
"@swc/core": "^1.15.11",
"@swc/helpers": "~0.5.19",
"@swc/jest": "^0.2.39",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/addressparser": "^1.0.3",
"@types/bcrypt": "^5.0.0",
"@types/bytes": "^3.1.1",
"@types/chrome": "^0.0.267",
"@types/deep-equal": "^1.0.1",
"@types/fs-extra": "^11.0.4",
"@types/graphql-fields": "^1.3.6",
"@types/inquirer": "^9.0.9",
"@types/jest": "^30.0.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.compact": "^3.0.9",
"@types/lodash.escaperegexp": "^4.1.9",
"@types/lodash.groupby": "^4.6.9",
"@types/lodash.identity": "^3.0.9",
"@types/lodash.isempty": "^4.4.7",
"@types/lodash.isequal": "^4.5.7",
"@types/lodash.isobject": "^3.0.7",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.mapvalues": "^4.6.9",
"@types/lodash.omit": "^4.5.9",
"@types/lodash.pickby": "^4.6.9",
"@types/lodash.snakecase": "^4.1.7",
"@types/lodash.upperfirst": "^4.3.7",
"@types/ms": "^0.7.31",
"@types/node": "^24.0.0",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "^3.0.8",
"@types/passport-microsoft": "^2.1.0",
"@types/pluralize": "^0.0.33",
"@types/react": "^18.2.39",
"@types/react-datepicker": "^6.2.0",
"@types/react-dom": "^18.2.15",
"@types/supertest": "^2.0.11",
"@types/uuid": "^9.0.2",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitejs/plugin-react-swc": "4.2.3",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"@yarnpkg/types": "^4.0.0",
"chromatic": "^6.18.0",
"concurrently": "^8.2.2",
"danger": "^13.0.4",
"dotenv-cli": "^7.4.4",
"esbuild": "^0.25.10",
"http-server": "^14.1.1",
"jest": "29.7.0",
"jest-environment-jsdom": "30.0.0-beta.3",
"jest-environment-node": "^29.4.1",
"jest-fetch-mock": "^3.0.3",
"jsdom": "~22.1.0",
"msw": "^2.12.7",
"msw-storybook-addon": "^2.0.6",
"nx": "22.5.4",
"prettier": "^3.1.1",
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.20",
"storybook": "^10.3.3",
"storybook-addon-mock-date": "2.0.0",
"storybook-addon-pseudo-states": "^10.3.3",
"supertest": "^6.1.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.2.3",
"ts-node": "10.9.1",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"verdaccio": "^6.3.1"
"verdaccio": "^6.3.1",
"vite": "^7.0.0",
"vitest": "^4.0.18"
},
"engines": {
"node": "^24.5.0",
@@ -33,9 +185,7 @@
"@lingui/core": "5.1.2",
"@types/qs": "6.9.16",
"@wyw-in-js/transform@npm:0.6.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch",
"@wyw-in-js/transform@npm:0.7.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch",
"@opentelemetry/api": "1.9.1",
"chokidar": "^3.6.0"
"@wyw-in-js/transform@npm:0.7.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch"
},
"version": "0.2.1",
"nx": {},
@@ -53,6 +203,7 @@
"packages/twenty-ui",
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
"packages/twenty-website-new",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
+1 -1
View File
@@ -1,7 +1,7 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/public/images/core/logo.svg" height="128">
<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>
+1 -6
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.3.0",
"version": "2.0.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -40,17 +40,12 @@
"uuid": "^13.0.0"
},
"devDependencies": {
"@swc/core": "^1.15.11",
"@swc/jest": "^0.2.39",
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/jest": "^30.0.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
-3
View File
@@ -26,7 +26,6 @@ const program = new Command(packageJson.name)
'--skip-local-instance',
'Skip the local Twenty instance setup prompt',
)
.option('-y, --yes', 'Auto-confirm prompts (e.g. start existing container)')
.helpOption('-h, --help', 'Display this help message.')
.action(
async (
@@ -37,7 +36,6 @@ const program = new Command(packageJson.name)
displayName?: string;
description?: string;
skipLocalInstance?: boolean;
yes?: boolean;
},
) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
@@ -61,7 +59,6 @@ const program = new Command(packageJson.name)
displayName: options?.displayName,
description: options?.description,
skipLocalInstance: options?.skipLocalInstance,
yes: options?.yes,
});
},
);
@@ -15,23 +15,5 @@
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}
}
@@ -11,9 +11,7 @@ import * as path from 'path';
import { basename } from 'path';
import {
authLoginOAuth,
checkDockerRunning,
ConfigService,
containerExists,
detectLocalServer,
serverStart,
type ServerStartResult,
@@ -29,7 +27,6 @@ type CreateAppOptions = {
displayName?: string;
description?: string;
skipLocalInstance?: boolean;
yes?: boolean;
};
export class CreateAppCommand {
@@ -74,7 +71,7 @@ export class CreateAppCommand {
let serverResult: ServerStartResult | undefined;
if (!options.skipLocalInstance) {
const shouldStartServer = await this.shouldStartServer(options.yes);
const shouldStartServer = await this.shouldStartServer();
if (shouldStartServer) {
const startResult = await serverStart({
@@ -226,35 +223,13 @@ export class CreateAppCommand {
);
}
private async shouldStartServer(autoConfirm?: boolean): Promise<boolean> {
private async shouldStartServer(): Promise<boolean> {
const existingServerUrl = await detectLocalServer();
if (existingServerUrl) {
return true;
}
if (checkDockerRunning() && containerExists()) {
if (autoConfirm) {
return true;
}
const { startExisting } = await inquirer.prompt([
{
type: 'confirm',
name: 'startExisting',
message:
'An existing Twenty server container was found. Would you like to start it?',
default: true,
},
]);
return startExisting;
}
if (autoConfirm) {
return true;
}
const { startDocker } = await inquirer.prompt([
{
type: 'confirm',
@@ -0,0 +1,6 @@
TWENTY_API_KEY=<SET_YOUR_TWENTY_API>
DAYS_AGO=<SET_YOUR_DAYS_AGO>
DISCORD_WEBHOOK_URL=<SET_YOUR_DISCORD_WEBHOOK_URL>
FB_GRAPH_TOKEN=<SET_YOUR_FB_GRAPH_TOKEN>
WHATSAPP_RECIPIENT_PHONE_NUMBER=<SET_YOUR_WHATSAPP_RECIPIENT_PHONE_NUMBER>
SLACK_HOOK_URL=<SET_YOUR_SLACK_HOOK_URL>
@@ -0,0 +1,2 @@
.yarn/install-state.gz
.env
@@ -0,0 +1,108 @@
# 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
@@ -0,0 +1,45 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: 'b53627f5-ca60-478c-bc43-c7ab4904e34a',
displayName: 'Activity Summary',
description:
'A TypeScript-based reporting bot that summarizes activity from your Twenty CRM workspace and sends daily/periodic reports to Slack, Discord, and WhatsApp. Meet Kylian Mbaguette, your friendly CRM activity reporter!',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '304b7d5d-e2bb-4444-9b04-6b3ae8b73730',
description: 'Twenty API Key',
isSecret: true,
},
DAYS_AGO: {
universalIdentifier: '040a3097-9cee-4f74-b957-c2f9bf636c3f',
description:
'How far back into the past we want to summarise defaults to the past 7 days',
value: '7',
isSecret: false,
},
SLACK_HOOK_URL: {
universalIdentifier: 'fd16e370-934c-4267-83b4-7d88259bf7e1',
description: 'Slack hook URL for sending message to channel',
isSecret: true,
},
DISCORD_WEBHOOK_URL: {
universalIdentifier: 'f3741075-d525-4988-ba42-55d519c6fd76',
description:
'Discord webhook URL for sending message to channel of a server',
isSecret: true,
},
FB_GRAPH_TOKEN: {
universalIdentifier: 'fb907f49-74ac-4aa5-ba45-cfc9250ecc44',
description: 'For Facebook auth',
isSecret: true,
},
WHATSAPP_RECIPIENT_PHONE_NUMBER: {
universalIdentifier: 'c856ee5d-44bf-42f4-9a39-2553a94af518',
description: 'Phone number for receiving WhatsApp message',
isSecret: true,
},
},
};
export default config;
@@ -0,0 +1,17 @@
{
"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"
}
}
@@ -0,0 +1,80 @@
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 * * *',
},
],
};
@@ -0,0 +1,28 @@
import { request } from "./utils"
type Opportunity = {
stage: 'NEW' | 'PROPOSAL'
}
export const summariseOpportunityCreation = async (date: string) => {
const { opportunities }: { opportunities: Opportunity[] } = await request(
`opportunities?filter=createdAt[gte]:${date}`,
)
if (opportunities.length === 0) {
return `- No Opportunities were added`
}
const stageSummary = Object.entries(
opportunities.reduce((hash: Record<string,number>, opportunity) => {
if (!hash[opportunity.stage]) {
hash[opportunity.stage] = 0
}
hash[opportunity.stage] += 1
return hash
}, {})
).map(([stage, value]) => `${value} in ${stage}`).join(', ')
return `- ${opportunities.length} Opportunities were added: ${stageSummary}`
}
@@ -0,0 +1,38 @@
import { request } from "./utils"
type Person = {
companyId: string
company: Company
}
type Company = {
id: string
accountOwnerId: string
}
export const summarisePeopleCreation = async (date: string) => {
const { people }: { people: Person[] } = await request(
`people?depth=1&filter=createdAt[gte]:${date}`,
)
if (people.length === 0) {
return '- No People were added'
}
let createdForCompanies: Record<string, Company> = {}
let numberOfAccountOwnerlessCompanies = 0
for (const person of people) {
const isCompanyTracked = createdForCompanies[person.companyId]
if (person.companyId && !isCompanyTracked) {
createdForCompanies[person.company.id] = person.company
if (!person?.company?.accountOwnerId) {
numberOfAccountOwnerlessCompanies += 1
}
}
}
return `- ${people.length} People were added for ${Object.keys(createdForCompanies).length} Companies
- Out of those, ${numberOfAccountOwnerlessCompanies} Companies don't have account owners yet`
}
@@ -0,0 +1,172 @@
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,
};
};
@@ -0,0 +1,89 @@
import { request } from "./utils"
type Task = {
status: string
dueAt: string
createdBy: {
workspaceMemberId: string
}
}
export const summariseTaskCreation = async (date: string) => {
const { tasks }: { tasks: Task[] } = await request(
`tasks?filter=createdAt[gte]:${date}`,
)
if (tasks.length === 0) {
return '- No Tasks were added'
}
const presentDateISOString = new Date().toISOString()
const workspaceMemberIdsDueDateCounter: Record<string, number> = {}
let workspaceMembers = []
tasks.forEach((task) => {
if (task.status === 'DONE') {
return
}
if (presentDateISOString >= task.dueAt) {
if (!workspaceMemberIdsDueDateCounter[task.createdBy.workspaceMemberId]) {
workspaceMemberIdsDueDateCounter[task.createdBy.workspaceMemberId] = 0
}
workspaceMemberIdsDueDateCounter[task.createdBy.workspaceMemberId] += 1
}
})
for (const { userId, count } of findMaxIncompleteKeys(workspaceMemberIdsDueDateCounter)) {
const data = await request(`workspaceMembers/${userId}`)
if (data.workspaceMember) {
workspaceMembers.push({
...data.workspaceMember,
slackCount: count
})
}
}
let slackerMessage = workspaceMembers.length > 0 ? workspaceMembers.reduce((text, member, index) => {
if (index === 0 && workspaceMembers.length === 1) {
return `${member.name.firstName} slacked the most with ${member.slackCount} Tasks overdue`
}
if (index !== workspaceMembers.length - 1) {
return text.concat(`, ${member.name.firstName}`)
}
if (index === workspaceMembers.length - 1) {
return text.concat(`, and ${member.name.firstName} slacked the most with ${member.slackCount} Tasks overdue`)
}
}, '') : 'No one was caught slacking!'
const tasksCompletedOnTime = tasks.filter(
task => task.status === 'DONE' && task.dueAt >= presentDateISOString
)
const taskCompletionPercentage = tasksCompletedOnTime.length > 0
? (tasksCompletedOnTime.length / tasks.length) * 100
: NaN
const taskCompletionMessage = isNaN(taskCompletionPercentage)
? 'No completed Tasks yet'
: `${taskCompletionPercentage.toFixed(2)}% of Tasks were completed on time`
return `- ${tasks.length} Tasks were created
- ${taskCompletionMessage}
- ${slackerMessage}`
}
/**
* @description This is generated by AI
*/
const findMaxIncompleteKeys = (workspaceMemberIdsDueDateCounter: Record<string,number>) => {
// Get the maximum incomplete count
const max = Math.max(...Object.values(workspaceMemberIdsDueDateCounter));
// Filter keys that match the maximum value
return Object.entries(workspaceMemberIdsDueDateCounter)
.filter(([_, count]) => count === max)
.map(([userId, count]) => ({ userId, count }))
};
@@ -0,0 +1,13 @@
export const request = async (route: string, authToken?: string) => {
const response = await fetch(
`https://api.twenty.com/rest/${route}`,
{
headers: {
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`
},
}
)
const json = await response.json()
return json.data
}
@@ -0,0 +1,26 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -0,0 +1,38 @@
# 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
@@ -0,0 +1,19 @@
# Set environment values for your application here.
# Use the format: KEY=value
#
# These variables are automatically loaded when running your serverless functions.
# You can access them directly in your code using:
# const myValue = process.env.KEY;
#
# To make these variables available to your application,
# add them to package.json "env" key. This "env" key defines all
# environment variables that will be provided to your serverless
# functions at runtime.
#
# Example:
# API_TOKEN=your-api-token
# TIMEOUT_MS=3000
TWENTY_API_URL=
TWENTY_API_KEY=
OPENAI_API_KEY=
@@ -0,0 +1,2 @@
.yarn/install-state.gz
.env
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,159 @@
# AI Meeting Transcript
Automatically process meeting transcripts to extract insights, action items, and follow-ups using AI.
## Features
- **Automatic Transcript Processing**: Receives meeting transcripts via webhook from Granola or similar transcription tools
- **AI-Powered Analysis**: Uses OpenAI to extract:
- Meeting summary
- Key discussion points
- Action items with assignees and due dates
- Commitments made by participants
- **Rich Note Creation**: Creates formatted notes in Twenty with summary and key points
- **Task Generation**: Automatically creates tasks for action items and commitments, linked to the meeting note
## Requirements
- `twenty-cli` - Install globally: `npm install -g twenty-cli`
- `apiKey` - Go to `https://twenty.com/settings/api-webhooks` to generate one
- `OpenAI API Key` - Get your API key from [OpenAI](https://platform.openai.com/api-keys)
## Installation
1. Copy the environment file:
```bash
cp .env.example .env
```
2. Edit `.env` and replace the placeholders:
- `<SET_YOUR_TWENTY_API>` with your Twenty API key
- `<SET_YOUR_OPENAI_API_KEY>` with your OpenAI API key
3. Install dependencies:
```bash
yarn install
```
4. Sync the app to your Twenty workspace:
```bash
twenty auth login
twenty app sync
```
## Configuration
After syncing, configure the environment variables in your Twenty workspace:
1. Go to Settings → Apps → AI Meeting Transcript
2. Set the following environment variables:
- `TWENTY_API_KEY` - Your Twenty API key
- `TWENTY_API_URL` - Your Twenty instance URL (e.g., `https://api.twenty.com` or `http://localhost:3000` for local development)
- `OPENAI_API_KEY` - Your OpenAI API key
**Important**: `TWENTY_API_URL` is required and must be set to your Twenty instance URL. For local development, use `http://localhost:3000`. For production, use your actual Twenty instance URL.
## Usage
### Webhook Endpoint
The app exposes a public serverless route trigger.
```
POST /s/webhook/transcript
```
Examples:
- Local: `POST http://localhost:3000/s/webhook/transcript`
- Hosted: `POST https://your-twenty-instance.com/s/webhook/transcript`
### Webhook Payload Format
Send a POST request with the following JSON structure:
```json
{
"transcript": "Full meeting transcript text here...",
"meetingTitle": "Q4 Planning Meeting",
"meetingDate": "2024-01-15",
"participants": ["John Doe", "Jane Smith"],
"metadata": {
"duration": "45 minutes",
"location": "Conference Room A"
}
}
```
**Required Fields:**
- `transcript` (string): The full meeting transcript text
**Optional Fields:**
- `meetingTitle` (string): Title of the meeting
- `meetingDate` (string): Date of the meeting (ISO format or readable date)
- `participants` (string[]): List of meeting participants
- `metadata` (object): Additional metadata about the meeting
### Example Webhook Call
```bash
curl -X POST https://your-twenty-instance.com/s/webhook/transcript \
-H "Content-Type: application/json" \
-d '{
"transcript": "John: Let'\''s start the meeting. Today we need to discuss Q4 goals. Jane: I agree. We should focus on customer retention. John: Great point. Can you prepare a report by Friday? Jane: Yes, I will have it ready.",
"meetingTitle": "Q4 Planning Meeting",
"meetingDate": "2024-01-15"
}'
```
### What Happens
1. **Transcript Analysis**: The transcript is sent to OpenAI for analysis
2. **Note Creation**: A formatted note is created in Twenty with:
- Meeting summary
- Key discussion points
- Reference to the transcript source
3. **Task Creation**: Tasks are automatically created for:
- Each action item identified
- Each commitment made by participants
- Tasks include a reference to the meeting note ID in their description
## Development
Run dev mode to see application updates on your workspace instantly:
```bash
twenty app dev
```
## Integration with Granola
To integrate with Granola or similar transcription tools:
1. Set up a webhook in your transcription service
2. Configure it to POST to: `https://your-twenty-instance.com/s/webhook/transcript`
3. Map the transcription service's payload format to the expected format above
### Granola Webhook Setup
If using Granola, configure the webhook to send:
- `transcript` field with the transcript text
- Optionally include meeting metadata fields
## API Response
The webhook returns a JSON response:
```json
{
"success": true,
"noteId": "uuid-of-created-note",
"taskIds": ["uuid-of-task-1", "uuid-of-task-2"],
"summary": {
"noteCreated": true,
"tasksCreated": 2,
"actionItemsProcessed": 1,
"commitmentsProcessed": 1
}
}
```
@@ -0,0 +1,30 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '028754f1-3235-43b9-9427-fa6a62dbd473',
displayName: 'AI Meeting Transcript',
description:
'Automatically process meeting transcripts to extract insights, action items, and follow-ups',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '1359d05c-4947-4673-809f-abd55bede365',
isSecret: true,
value: '',
description: 'Twenty API key',
},
TWENTY_API_URL: {
universalIdentifier: 'dbe83355-b574-445c-92c0-5c2b94a61ddb',
isSecret: true,
value: '',
description: 'Twenty API URL',
},
OPENAI_API_KEY: {
universalIdentifier: '9559470d-15eb-4bc2-9cbc-3bc5c869d1fd',
isSecret: true,
value: '',
description: 'OpenAI API key for transcript analysis',
},
},
};
export default config;
@@ -0,0 +1,19 @@
{
"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"
}
}
@@ -0,0 +1,306 @@
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 RichTextData = {
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 RichTextData,
};
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: RichTextData;
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,
},
],
};
@@ -0,0 +1,26 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -0,0 +1,435 @@
# 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
@@ -0,0 +1,38 @@
# 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/
.twenty/*
!.twenty/output/
# 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
@@ -0,0 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,13 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/fixtures/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -0,0 +1,51 @@
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 twenty remote add http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## 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!
@@ -0,0 +1,26 @@
{
"name": "apollo-enrich",
"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": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,54 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: 'ac1d2ed1-8835-4bd4-9043-28b46fdda465',
displayName: 'Apollo enrichment',
description: 'Data enrichment with Apollo to keep your data accurate',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
settingsCustomTabFrontComponentUniversalIdentifier: '50d59f7c-eada-4731-aacd-8e45371e1040',
applicationVariables: {
APOLLO_CLIENT_ID: {
universalIdentifier: '5852219e-7757-463e-9e7c-80980203794c',
isSecret: true,
value: '',
description: 'Apollo Client ID',
},
APOLLO_CLIENT_SECRET: {
universalIdentifier: 'a032349d-9458-4381-8505-82547276434a',
isSecret: true,
value: '',
description: 'Apollo Client Secret',
},
APOLLO_OAUTH_URL: {
universalIdentifier: '1d42411c-5809-4093-873a-8121b1302475',
isSecret: false,
value: '',
description: 'Apollo OAuth URL',
},
APOLLO_REDIRECT_URI: {
universalIdentifier: 'c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f',
isSecret: false,
value: '',
description: 'Apollo OAuth redirect URI',
},
APOLLO_REGISTERED_URL: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620770',
isSecret: false,
value: '',
description: 'Apollo registered URL',
},
APOLLO_ACCESS_TOKEN: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620771',
isSecret: true,
value: '',
description: 'Apollo access token',
},
APOLLO_REFRESH_TOKEN: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620772',
isSecret: true,
value: '',
description: 'Apollo refresh token',
},
},
});
@@ -0,0 +1,12 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'da15cfc6-3657-457d-8757-4ba11b5bb6e1',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.NUMBER,
name: 'apolloFoundedYear',
label: 'Founded Year',
description: 'Year the company was founded, from Apollo enrichment',
icon: 'IconCalendar',
});
@@ -0,0 +1,12 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '505532f5-1fc5-4a58-8074-ba9b48650dbc',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.TEXT,
name: 'apolloIndustry',
label: 'Apollo Industry',
description: 'Industry classification from Apollo enrichment',
icon: 'IconBuildingFactory',
});
@@ -0,0 +1,12 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'be15e062-b065-48b4-979c-65b9a50e0cb1',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.TEXT,
name: 'apolloShortDescription',
label: 'Apollo Description',
description: 'Short company description from Apollo enrichment',
icon: 'IconFileDescription',
});
@@ -0,0 +1,12 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'c90ae72d-4ddf-4f22-882f-eef98c91e40e',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.CURRENCY,
name: 'apolloTotalFunding',
label: 'Total Funding',
description: 'Total funding raised by the company, from Apollo enrichment',
icon: 'IconCash',
});
@@ -0,0 +1,220 @@
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { OAuthApplicationVariables } from 'src/logic-functions/get-oauth-application-variables';
import { VERIFY_PAGE_PATH } from 'src/logic-functions/get-verify-page';
import { defineFrontComponent } from 'twenty-sdk/define';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
`;
const StyledSectionTitle = styled.h3`
color: #333;
font-family: 'Inter', sans-serif;
font-size: 15px;
font-weight: 600;
margin: 0 0 4px 0;
`;
const StyledSectionSubtitle = styled.p`
color: #818181;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 400;
margin: 0 0 12px 0;
`;
const StyledCard = styled.div`
align-items: center;
background: #fff;
border: 1px solid #ebebeb;
border-radius: 8px;
display: flex;
gap: 12px;
padding: 16px;
`;
const StyledIconContainer = styled.div`
align-items: center;
background: #f5f5f5;
border-radius: 8px;
color: #666;
display: flex;
flex-shrink: 0;
height: 40px;
justify-content: center;
width: 40px;
`;
const StyledTextContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const StyledTitle = styled.span`
color: #333;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
`;
const StyledDescription = styled.span`
color: #818181;
font-family: 'Inter', sans-serif;
font-size: 13px;
`;
const StyledLink = styled.a`
align-items: center;
background: #5e5adb;
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 4px;
box-sizing: border-box;
color: #fafafa;
cursor: pointer;
display: inline-flex;
flex-shrink: 0;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
gap: 4px;
height: 32px;
justify-content: center;
padding: 0 12px;
text-decoration: none;
transition: background 0.1s ease;
white-space: nowrap;
&:hover {
background: #4b47b8;
}
&:active {
background: #3c3996;
}
&:focus {
outline: none;
}
`;
const StyledConnectedStatus = styled.span`
align-items: center;
background: #10b981;
border-radius: 4px;
color: #fff;
display: inline-flex;
flex-shrink: 0;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
gap: 6px;
height: 32px;
padding: 0 12px;
white-space: nowrap;
`;
const StyledIcon = styled.img`
height: 24px;
width: 24px;
`;
const APOLLO_ICON_URL = 'https://twenty-icons.com/apollo.io';
const fetchOAuthApplicationVariables = async (): Promise<OAuthApplicationVariables> => {
const backEndUrl = `${process.env.TWENTY_API_URL}/s/oauth/application-variables`;
const response = await fetch(backEndUrl, {
method: 'GET',
});
const data = await response.json();
return data;
};
const buildOAuthUrl = (oauthApplicationVariables: OAuthApplicationVariables): string => {
const { apolloOAuthUrl, apolloClientId, apolloRegisteredUrl } = oauthApplicationVariables;
const redirectUri = `${apolloRegisteredUrl}auth/oauth-propagator/callback`;
const state = encodeURIComponent(`${process.env.TWENTY_API_URL}/s${VERIFY_PAGE_PATH}`);
return `${apolloOAuthUrl}?client_id=${apolloClientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code`;
};
const ApolloOAuthCta = () => {
const [oauthApplicationVariables, setOAuthApplicationVariables] =
useState<OAuthApplicationVariables | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchOAuthApplicationVariables()
.then(setOAuthApplicationVariables)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) {
return (
<StyledContainer>
<StyledSectionTitle>Connect to Apollo</StyledSectionTitle>
<StyledSectionSubtitle>Enrich your contacts with Apollo data</StyledSectionSubtitle>
<StyledCard>
<StyledIconContainer>
<StyledIcon src={APOLLO_ICON_URL} alt="Apollo" />
</StyledIconContainer>
<StyledTextContainer>
<StyledTitle>Apollo OAuth</StyledTitle>
<StyledDescription>Loading...</StyledDescription>
</StyledTextContainer>
</StyledCard>
</StyledContainer>
);
}
if (error || !oauthApplicationVariables) {
return null;
}
const isConnected = Boolean(oauthApplicationVariables.apolloAccessToken);
const oauthUrl = buildOAuthUrl(oauthApplicationVariables);
return (
<StyledContainer>
<StyledSectionTitle>Connect to Apollo</StyledSectionTitle>
<StyledSectionSubtitle>Enrich your contacts with Apollo data</StyledSectionSubtitle>
<StyledCard>
<StyledIconContainer>
<StyledIcon src={APOLLO_ICON_URL} alt="Apollo" />
</StyledIconContainer>
<StyledTextContainer>
<StyledTitle>Apollo OAuth</StyledTitle>
<StyledDescription>
{isConnected
? 'Your Apollo account is connected'
: 'Connect your Apollo account to enrich contacts'}
</StyledDescription>
</StyledTextContainer>
{isConnected ? (
<StyledConnectedStatus>
Connected
</StyledConnectedStatus>
) : (
<StyledLink href={oauthUrl} rel="noopener noreferrer">
Connect
</StyledLink>
)}
</StyledCard>
</StyledContainer>
);
};
export default defineFrontComponent({
universalIdentifier: '50d59f7c-eada-4731-aacd-8e45371e1040',
name: 'apollo-oauth-cta',
description: 'CTA button to connect to Apollo Enrichment via OAuth',
component: ApolloOAuthCta,
});
@@ -0,0 +1,96 @@
import { defineLogicFunction, RoutePayload } from "twenty-sdk/define";
import { MetadataApiClient } from 'twenty-sdk/clients';
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
type ApolloTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
created_at: number;
};
const getAuthenticationTokenPairs = async (
code: string,
clientId: string,
clientSecret: string,
): Promise<ApolloTokenResponse> => {
const formData = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code: code,
redirect_uri: 'https://hjsm0q38-3000.uks1.devtunnels.ms/auth/oauth-propagator/callback',
});
const response = await fetch('https://app.apollo.io/api/v1/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to exchange code for tokens: ${response.status} - ${errorText}`);
}
return response.json();
};
const handler = async (event: RoutePayload): Promise<any> => {
const { queryStringParameters: { code } } = event;
if (!code) {
throw new Error('Code is required');
}
const apolloClientId = process.env.APOLLO_CLIENT_ID ?? '';
const apolloClientSecret = process.env.APOLLO_CLIENT_SECRET ?? '';
const applicationId = process.env.APPLICATION_ID ?? '';
const metadataClient = new MetadataApiClient({});
const tokenPairs = await getAuthenticationTokenPairs(
code,
apolloClientId,
apolloClientSecret,
);
await metadataClient.mutation({
updateOneApplicationVariable: {
__args: {
key: 'APOLLO_ACCESS_TOKEN',
value: tokenPairs.access_token,
applicationId,
},
},
});
await metadataClient.mutation({
updateOneApplicationVariable: {
__args: {
key: 'APOLLO_REFRESH_TOKEN',
value: tokenPairs.refresh_token,
applicationId,
},
},
});
return {tokenPairs};
};
export default defineLogicFunction({
universalIdentifier: '7ccc63a7-ece1-44c0-adbe-805a1baea03a',
name: 'get-authentication-token-pairs',
description: 'Returns the Apollo authentication token pairs',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: OAUTH_TOKEN_PAIRS_PATH,
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -0,0 +1,32 @@
import { defineLogicFunction } from 'twenty-sdk/define';
export type OAuthApplicationVariables = {
apolloClientId: string;
apolloRegisteredUrl: string;
apolloOAuthUrl: string;
apolloAccessToken: string;
apolloRefreshToken: string;
};
const handler = async (): Promise<OAuthApplicationVariables> => {
const apolloClientId = process.env.APOLLO_CLIENT_ID ?? '';
const apolloRegisteredUrl = process.env.APOLLO_REGISTERED_URL ?? '';
const apolloOAuthUrl = process.env.APOLLO_OAUTH_URL ?? '';
const apolloAccessToken = process.env.APOLLO_ACCESS_TOKEN ?? '';
const apolloRefreshToken = process.env.APOLLO_REFRESH_TOKEN ?? '';
return { apolloClientId, apolloRegisteredUrl, apolloOAuthUrl, apolloAccessToken, apolloRefreshToken };
};
export default defineLogicFunction({
universalIdentifier: 'b7c3e8f1-9d4a-4e2b-8f6c-1a5d3e7b9c2f',
name: 'get-oauth-application-variables',
description: 'Returns the Apollo OAuth authorization URL',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/oauth/application-variables',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -0,0 +1,90 @@
import { defineLogicFunction } from "twenty-sdk/define";
export const VERIFY_PAGE_PATH = '/oauth/verify';
const buildVerifyPageHtml = (applicationId: string): string => `<!DOCTYPE html>
<html>
<head>
<title>Apollo OAuth - Verifying...</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center; }
.loading { color: #6b7280; }
.success { color: #10b981; }
.error { color: #ef4444; }
.spinner { border: 3px solid #f3f4f6; border-top: 3px solid #3b82f6; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
<script>
(async function() {
const applicationId = ${JSON.stringify(applicationId)};
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const baseUrl = window.location.origin;
function showError(message) {
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('spinner').style.display = 'none';
document.getElementById('title').textContent = '✗ Connection Failed';
document.getElementById('title').className = 'error';
document.getElementById('status').textContent = message;
});
if (window.opener) {
window.opener.postMessage({ type: 'APOLLO_OAUTH_ERROR', error: message }, '*');
}
}
if (!code) {
showError('Authorization code is missing. Please try connecting again.');
return;
}
try {
const response = await fetch(
baseUrl + '/s/oauth/token-pairs?code=' + encodeURIComponent(code),
{
method: 'GET',
headers: { 'Content-Type': 'application/json' }
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error('Failed to get tokens: ' + response.status + ' - ' + errorText);
}
const tokens = await response.json();
window.location.href = 'http://apple.localhost:3001/settings/applications/' + applicationId + '#custom';
} catch (error) {
showError(error.message);
}
})();
</script>
</head>
<body>
<div class="spinner" id="spinner"></div>
<h1 class="loading" id="title">Connecting to Apollo...</h1>
<p id="status">Please wait while we complete the connection.</p>
</body>
</html>`;
const handler = async (): Promise<string> => {
const applicationId = process.env.APPLICATION_ID ?? '';
return buildVerifyPageHtml(applicationId);
};
export default defineLogicFunction({
universalIdentifier: '4d74950a-d9c1-4c66-a799-89c1aea4e6b0',
name: 'get-verify-page',
description: 'Returns the Apollo OAuth verify page',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: VERIFY_PAGE_PATH,
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -0,0 +1,223 @@
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordUpdateEvent } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-sdk/clients';
type CompanyRecord = {
id: string;
name?: string;
domainName?: {
primaryLinkUrl?: string;
primaryLinkLabel?: string;
};
};
type ApolloOrganization = {
name?: string;
website_url?: string;
linkedin_url?: string;
twitter_url?: string;
estimated_num_employees?: number;
annual_revenue?: number;
total_funding?: number;
street_address?: string;
city?: string;
state?: string;
postal_code?: string;
country?: string;
short_description?: string;
industry?: string;
founded_year?: number;
};
type ApolloEnrichResponse = {
organization?: ApolloOrganization;
};
const extractDomain = (
domainName?: CompanyRecord['domainName'],
): string | undefined => {
const url = domainName?.primaryLinkUrl;
if (!url) {
return undefined;
}
try {
const hostname = new URL(
url.startsWith('http') ? url : `https://${url}`,
).hostname;
return hostname.replace(/^www\./, '');
} catch {
return url.replace(/^(https?:\/\/)?(www\.)?/, '').split('/')[0];
}
};
const fetchApolloEnrichment = async (
domain: string,
): Promise<ApolloOrganization | undefined> => {
const response = await fetch(
`https://api.apollo.io/api/v1/organizations/enrich?domain=${encodeURIComponent(domain)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.APOLLO_ACCESS_TOKEN ?? ''}`,
},
},
);
const data: ApolloEnrichResponse = await response.json();
return data.organization;
};
const buildCompanyUpdateData = (
apolloOrganization: ApolloOrganization,
): Record<string, unknown> => {
const updateData: Record<string, unknown> = {};
if (apolloOrganization.name) {
updateData.name = apolloOrganization.name;
}
if (apolloOrganization.estimated_num_employees) {
updateData.employees = apolloOrganization.estimated_num_employees;
}
if (apolloOrganization.linkedin_url) {
updateData.linkedinLink = {
primaryLinkUrl: apolloOrganization.linkedin_url,
primaryLinkLabel: 'LinkedIn',
};
}
if (apolloOrganization.twitter_url) {
updateData.xLink = {
primaryLinkUrl: apolloOrganization.twitter_url,
primaryLinkLabel: 'X',
};
}
if (apolloOrganization.annual_revenue) {
updateData.annualRecurringRevenue = {
amountMicros: apolloOrganization.annual_revenue * 1_000_000,
currencyCode: 'USD',
};
}
const hasAddress =
apolloOrganization.street_address ||
apolloOrganization.city ||
apolloOrganization.state ||
apolloOrganization.country;
if (hasAddress) {
updateData.address = {
addressStreet1: apolloOrganization.street_address ?? '',
addressCity: apolloOrganization.city ?? '',
addressState: apolloOrganization.state ?? '',
addressPostcode: apolloOrganization.postal_code ?? '',
addressCountry: apolloOrganization.country ?? '',
};
}
if (apolloOrganization.industry) {
updateData.apolloIndustry = apolloOrganization.industry;
}
if (apolloOrganization.short_description) {
updateData.apolloShortDescription = apolloOrganization.short_description;
}
if (apolloOrganization.founded_year) {
updateData.apolloFoundedYear = apolloOrganization.founded_year;
}
if (apolloOrganization.total_funding) {
updateData.apolloTotalFunding = {
amountMicros: apolloOrganization.total_funding * 1_000_000,
currencyCode: 'USD',
};
}
return updateData;
};
const updateCompanyInTwenty = async (
companyId: string,
updateData: Record<string, unknown>,
): Promise<void> => {
const client = new CoreApiClient();
const result = await client.mutation({
updateCompany: {
__args: {
id: companyId,
data: updateData,
},
id: true,
},
});
if (!result.updateCompany) {
throw new Error(`Failed to update company ${companyId}: no result`);
}
};
type CompanyUpdateEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<CompanyRecord>
>;
const handler = async (
event: CompanyUpdateEvent,
): Promise<object | undefined> => {
const { recordId, properties } = event;
const { after: companyAfter } = properties;
const domain = extractDomain(companyAfter?.domainName);
if (!domain) {
return { skipped: true, reason: 'no domain found on company' };
}
const apolloOrganization = await fetchApolloEnrichment(domain);
if (!apolloOrganization) {
return {
skipped: true,
reason: `no Apollo data found for ${domain}`,
};
}
const updateData = buildCompanyUpdateData(apolloOrganization);
if (Object.keys(updateData).length === 0) {
return { skipped: true, reason: 'no enrichment data to apply' };
}
await updateCompanyInTwenty(recordId, updateData);
const result = {
enriched: true,
companyId: recordId,
domain,
updatedFields: Object.keys(updateData),
};
return result;
};
export default defineLogicFunction({
universalIdentifier: '6248b3fe-a8af-404a-8e38-19df98f73d81',
name: 'on-company-updated',
description:
'Enriches company data from Apollo when the company domain is updated',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'company.updated',
updatedFields: ['domainName'],
},
});
@@ -0,0 +1,13 @@
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
};
export default definePostInstallLogicFunction({
universalIdentifier: '08292efc-d7ba-4ec3-ab95-e7c33bd3a3bc',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
@@ -0,0 +1,13 @@
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: 'af7cd86e-149e-466a-8d60-312b6e46d604',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
@@ -0,0 +1,15 @@
import { defineRole } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b8faae3f-e174-43fa-ab94-715712ae26cb';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Apollo enrich default function role',
description: 'Apollo enrich default function role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: true,
});
@@ -5,7 +5,8 @@
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
@@ -15,9 +16,9 @@
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2020",
"target": "es2018",
"module": "esnext",
"lib": ["es2020"],
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
# Fireflies Integration Environment Variables
# Copy this file to .env and fill in your actual values
# =============================================================================
# REQUIRED: Authentication & API Keys
# =============================================================================
# Secret key for HMAC signature verification of Fireflies webhooks
# This must match the secret configured in your Fireflies webhook settings
# Get this from: https://app.fireflies.ai/settings#DeveloperSettings
FIREFLIES_WEBHOOK_SECRET=your_webhook_secret_here
# Fireflies API key for fetching meeting data from GraphQL API
# Get this from: https://app.fireflies.ai/settings#DeveloperSettings
FIREFLIES_API_KEY=your_fireflies_api_key_here
# Fireflies plan level - affects which fields are available
# Options: free, pro, business, enterprise
# This controls which GraphQL fields are requested to avoid 403 errors
FIREFLIES_PLAN=free
# Twenty CRM API key for authentication
# Generate this from your Twenty instance: Settings > Developers > API Keys
TWENTY_API_KEY=your_twenty_api_key_here
# =============================================================================
# Server Configuration
# =============================================================================
# Twenty CRM server URL
# Use http://localhost:3000 for local development
# Use your production URL for deployed instances
SERVER_URL=http://localhost:3000
# =============================================================================
# Contact Management
# =============================================================================
# Automatically create contacts for unknown participants (true/false)
# If true, new Person records will be created for meeting participants not found in CRM
# If false, only meetings with existing contacts will be fully processed
AUTO_CREATE_CONTACTS=true
# =============================================================================
# Summary Processing Configuration
# =============================================================================
# Strategy for handling async summary generation
# Options:
# - immediate_with_retry: Attempts immediate fetch with retry logic (RECOMMENDED)
# - delayed_polling: Schedules background polling for summaries
# - basic_only: Creates records without waiting for summaries
FIREFLIES_SUMMARY_STRATEGY=immediate_with_retry
# Number of retry attempts when fetching summary data
# Used with immediate_with_retry strategy
# Recommended: 3-5 attempts (summaries can take up to 10 minutes but rate limit is low)
FIREFLIES_RETRY_ATTEMPTS=5
# Delay in milliseconds between retry attempts (with exponential backoff)
# Each retry will wait: RETRY_DELAY * attempt_number
# Example: 120000ms means 2min, 4min, 6min... for extended backoff
# Total max time with 5 attempts: ~15 minutes
FIREFLIES_RETRY_DELAY=120000
# Polling interval in milliseconds for delayed_polling strategy
# How often to check if summary is ready
# Recommended: 60000 (60 seconds) for extended processing
FIREFLIES_POLL_INTERVAL=120000
# Maximum number of polling attempts for delayed_polling strategy
# Total max time = POLL_INTERVAL * MAX_POLLS
# Example: 2min * 5 = 10 minutes maximum
FIREFLIES_MAX_POLLS=5
# =============================================================================
# Debugging & Logging
# =============================================================================
# Log level: silent, error, warn, info, debug (default: error)
# Controls verbosity of console output
# - silent: No console output
# - error: Only errors (production default)
# - warn: Warnings and errors
# - info: Info, warnings, and errors
# - debug: All logs including detailed debugging
LOG_LEVEL=error
# =============================================================================
# Configuration Notes
# =============================================================================
#
# Webhook Setup:
# 1. Configure your Fireflies webhook at: https://app.fireflies.ai/settings#DeveloperSettings
# 2. Webhook URL: https://your-twenty-instance.com/s/webhook/fireflies
# 3. Event Type: "Transcription completed"
# 4. Secret: Same value as FIREFLIES_WEBHOOK_SECRET above, genereate it there
#
# Summary Strategy Guide:
# - immediate_with_retry: Best for most use cases - fast with reliability
# - delayed_polling: Use if your server is heavily loaded
# - basic_only: Use if you only need transcript links without AI summaries
#
# Performance Tuning:
# - Fireflies summaries can take 5-15 minutes to generate after transcription
# - Use 30+ retry attempts with 30s delay for 15-minute coverage
# - Consider delayed_polling strategy for heavily loaded servers
# - Monitor DEBUG_LOGS to tune timing for your Fireflies account
#
@@ -0,0 +1 @@
.yarn
@@ -0,0 +1,40 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,167 @@
# Changelog
## [0.3.1] - 2025-12-08
Import all
### Added
- Historical import CLI: `yarn meeting:all` to fetch and insert historical Fireflies meetings with filters (date range, organizers, participants, channel, mine) and dry-run support.
- Fireflies transcripts listing with pagination and date filtering to support bulk imports.
### Changed
- Deduplication now checks `firefliesMeetingId` before creating meetings (webhook + bulk).
- Shared historical importer pipeline reusing existing note/meeting formatting.
## [0.3.0] - 2025-12-08
Subscription-based query / Full transcript and AI notes for Pro+ / More
### Added
- **Full transcript capture**: Meeting object now stores the complete meeting transcript with speaker names and timestamps (`transcript` field)
- **Rich AI meeting notes**: Captures detailed AI-generated meeting notes from Fireflies (`notes` field with 7,000+ char summaries)
- **Expanded summary fields**: Now fetches all available Fireflies summary data:
- `notes` - Detailed AI-generated meeting notes with timestamps and section headers
- `bullet_gist` - Emoji-enhanced bullet point summaries
- `outline` / `shorthand_bullet` - Timestamped meeting outline
- `gist` - One-sentence meeting summary
- `short_summary` - Single paragraph summary
- `short_overview` - Brief overview
- **New Meeting fields**:
- `transcript` - Full meeting transcript with speaker attribution
- `notes` - AI-generated detailed notes
- `audioUrl` - Link to audio recording (Pro+)
- `videoUrl` - Link to video recording (Business+)
- `meetingLink` - Original meeting link
- `neutralPercent` - Neutral sentiment percentage
- **Meeting delete utility**: New `yarn meeting:delete <meetingId>` script for cleanup and re-import
- **Debug meeting utility**: New `scripts/debug-meeting.ts` to inspect raw Fireflies API responses
### Changed
- **Plan-based GraphQL queries**: Completely redesigned query system with three tiers:
- **Free**: Basic fields only (title, date, duration, participants, transcript_url, meeting_link)
- **Pro**: Adds full transcript (`sentences`), summary fields, speakers, audio_url
- **Business+**: Adds analytics, video_url, speaker stats, meeting metrics
- **Action items parsing**: Fixed parsing of `action_items` which Fireflies returns as newline-separated string, not array
- **Note body format**: Enhanced with Meeting Notes, Outline, Key Points sections from rich Fireflies data
- **Import status**: Added `PARTIAL` status for imports missing summary/analytics data
### Fixed
- Missing `notes` and `bullet_gist` fields in data transform (were fetched but not passed through)
- Proper fallback: Uses `shorthand_bullet` when `outline` is empty (Fireflies stores outline content there)
- Summary readiness detection now checks `notes` field in addition to `overview` and `action_items`
### Documentation
- Updated README with complete API access comparison table by subscription plan
- Documented all available Fireflies summary fields and their plan requirements
## [0.2.3] - 2025-12-06
### Added
- **Meeting ingest utility**: New `yarn meeting:ingest <meetingId>` script to manually fetch and import specific Fireflies meetings into Twenty
- **Plan-based field selection**: Added `FIREFLIES_PLAN` configuration to control which GraphQL fields are requested based on subscription level (free, pro, business, enterprise)
- **Main entry point**: New `src/index.ts` centralizing all exports for cleaner imports
### Changed
- **Auth configuration**: Disabled authentication requirement for webhook route (`isAuthRequired: false`) to support serverless deployments
- **Signature verification fallback**: Webhook handler now supports signature in payload body as fallback when HTTP headers aren't forwarded to serverless functions (production doesn't work for Fireflies webhook)
- **Improved type safety**: Replaced `any` types with proper TypeScript types throughout codebase
### Enhanced
- **Webhook debugging**: Added detailed debug output including param keys, header info, and signature comparison details
- **Test webhook script**: Includes signature in both header and payload, with diagnostic output for header forwarding status
- **Documentation**: Added README sections on current twenty headers forward limitations and utility scripts
## [0.2.2] - 2025-11-04
### Added
- **Enhanced logging system**: Introduced configurable `AppLogger` class with log level support (debug, info, warn, error, silent)
- Environment-based log level configuration via `LOG_LEVEL` environment variable
- Test environment detection to prevent log noise during testing
- Context-aware logging with proper prefixes for better debugging
- **Improved error handling**: Enhanced webhook signature verification with detailed debug logging
- **Better debugging capabilities**: Added comprehensive logging throughout webhook processing pipeline
### Enhanced
- **Webhook signature verification**: Improved signature validation with detailed logging for troubleshooting
- **Error messages**: More descriptive error logging for failed operations and security violations
- **Development experience**: Better debugging information for webhook processing and API interactions
## [0.2.1] - 2025-11-03
### Added
- **Import status tracking**: Added four new meeting fields to track import status and failure handling:
- `importStatus` (SELECT) - Tracks SUCCESS, FAILED, PENDING, RETRYING states
- `importError` (TEXT) - Stores error messages when imports fail
- `lastImportAttempt` (DATE_TIME) - Timestamp of the last import attempt
- `importAttempts` (NUMBER) - Counter for number of import attempts
- **Automatic failure tracking**: Enhanced webhook handler to automatically create failed meeting records when processing fails
- **Failed meeting formatter**: Added `toFailedMeetingCreateInput()` method to create standardized failed meeting records
### Enhanced
- **Meeting type definition**: Extended `MeetingCreateInput` type with import tracking fields
- **Success status tracking**: Successful meeting imports now automatically set `importStatus: 'SUCCESS'` and track timestamps
- **Error handling**: Webhook processing failures are now captured and stored as meeting records for visibility and potential retry
## [0.2.0] - 2025-11-03
### Changed
- **Major refactoring**: Split monolithic `receive-fireflies-notes.ts` into modular architecture:
- `fireflies-api-client.ts` - Fireflies GraphQL API integration with retry logic
- `twenty-crm-service.ts` - Twenty CRM operations (contacts, notes, meetings)
- `formatters.ts` - Meeting and note body formatting
- `webhook-handler.ts` - Main webhook orchestration
- `webhook-validator.ts` - HMAC signature verification
- `utils.ts` - Shared utility functions
- `types.ts` - Centralized type definitions
- **Schema update**: Changed Meeting `notes` field from `RICH_TEXT` to `RELATION` type linking to Note object
- Enhanced participant extraction from multiple Fireflies API data sources (participants, meeting_attendees, speakers, meeting_attendance)
- Improved organizer email matching with name-based heuristics
- Updated note creation to use `bodyV2.markdown` format instead of legacy `body` field
- Modernized Meeting object schema with proper link field types for transcriptUrl and recordingUrl
- Enhanced test suite with improved mocking for new modular structure
- **Configuration optimization**: Reduced default retry attempts from 30 to 5 with increased delay (120s) to better respect Fireflies API rate limits (50 requests/day for free/pro plans)
- Updated field setup script to support relation field creation with Note object
- Restructured exports: types now exported from `types.ts`, runtime functions from `index.ts`
- Updated import paths in action handlers to use centralized index exports
- Added TypeScript path mappings for `twenty-sdk` in workspace configuration
### Added
- `createNoteTarget` method for linking notes to multiple participants
- Support for extracting participants from extended Fireflies API response formats
- Better organizer identification logic matching email usernames to speaker names
- `axios` dependency for improved HTTP client capabilities
- API subscription plan documentation highlighting rate limit differences (50/day vs 60/minute)
- Enhanced README with rate limiting guidance and configuration documentation
- Relation field creation support in field provisioning script
### Fixed
- Note linking now properly associates a single note with multiple participants in 1:1 meetings
- Participant extraction handles missing email addresses gracefully
- Improved handling of various Fireflies participant data structures
- Test mocks updated to use string format for participants (`"Name <email>"`) matching Fireflies API response format
- Test assertions updated to validate `bodyV2.markdown` instead of deprecated `body` field
## [0.1.0] - 2025-11-02
### Added
- HMAC SHA-256 signature verification for incoming Fireflies webhooks
- Fireflies GraphQL client with retry logic, timeout handling, and summary readiness detection
- Summary-focused meeting processing that extracts action items, sentiment, keywords, and transcript/recording links
- Scripted custom field provisioning via `yarn setup:fields`
- Local webhook testing workflow via `yarn test:webhook`
- Comprehensive Jest suite (15 tests) covering authentication, API integration, summary strategies, and error handling
### Changed
- Replaced legacy JSON manifests with TypeScript configuration:
- `application.config.ts` now declares app metadata and configuration variables
- `src/objects/meeting.ts` defines the Meeting object via `@ObjectMetadata`
- `src/actions/receive-fireflies-notes.ts` exports the Fireflies webhook action plus its runtime config
- Updated documentation (README, Deployment Guide, Testing) to reflect the new project layout and workflows
- Switched utility scripts to `tsx` and aligned package management with the hello-world example
### Fixed
- Resolved real-world Fireflies payload mismatch by adopting the minimal webhook schema
- Replaced body-based secrets with header-driven HMAC verification
- Ensured graceful degradation when summaries are pending or Fireflies is temporarily unavailable
@@ -0,0 +1,331 @@
# Fireflies
Automatically captures meeting notes with AI-generated summaries and insights from Fireflies.ai into your Twenty CRM.
### Current Status
- Doesn't work with Fireflies webhook yet due to missing headers forwarding in twenty serverless func
- Meeting ingestion utility scripts are available for individual meeting insertion and historical meetings with filters with yarn meeting:all
## Integration Overview
**Fireflies webhook → Fireflies API → Twenty CRM with summary-focused insights**
- **Summary-first approach** - Prioritizes action items, keywords, and sentiment over raw transcripts
- **HMAC signature verification** - Secure webhook authentication
- **Two-phase architecture** - Webhook notification → API data fetch → CRM record creation
- **Contact identification** - Matches participants to existing contacts or creates new ones
- **One-on-one meetings** (2 people) → Individual notes linked to each contact
- **Multi-party meetings** (3+ people) → Meeting records with all attendees
- **Business intelligence extraction** - Action items, sentiment scores, topics, meeting types
- **Smart retry logic** - Handles async summary generation with exponential backoff
- **Links transcripts and recordings** - Easy access to full Fireflies content
- **Duplicate prevention** - Checks for existing meetings by title
## API Access by Subscription Plan
Fireflies API access varies by subscription tier. This integration automatically adapts queries based on your plan and falls back gracefully if restrictions are encountered.
### Plan Comparison
| Feature | Free | Pro | Business | Enterprise |
|---------|:----:|:---:|:--------:|:----------:|
| **API Rate Limit** | 50/day | 50/day | 60/min | 60/min |
| **Basic Data** (title, date, duration) | ✅ | ✅ | ✅ | ✅ |
| **Participants List** | ✅ | ✅ | ✅ | ✅ |
| **Transcript URL** | ✅ | ✅ | ✅ | ✅ |
| **Speakers** | ❌ | ✅ | ✅ | ✅ |
| **Summary** (overview, keywords) | ❌ | ✅ | ✅ | ✅ |
| **Audio URL** | ❌ | ✅ | ✅ | ✅ |
| **Action Items** | ❌ | ❌ | ✅ | ✅ |
| **Topics Discussed** | ❌ | ❌ | ✅ | ✅ |
| **Video URL** | ❌ | ❌ | ✅ | ✅ |
| **Sentiment Analytics** | ❌ | ❌ | ✅ | ✅ |
| **Meeting Attendees (detailed)** | ❌ | ❌ | ✅ | ✅ |
### What You'll Get Per Plan
**Free Plan:**
- Meeting title, date, duration
- Participant names/emails (basic)
- Link to transcript
**Pro Plan:**
- Everything in Free, plus:
- Speaker identification
- AI summary (overview + keywords)
- Audio recording URL
**Business Plan:**
- Everything in Pro, plus:
- Action items extraction
- Topics discussed
- Sentiment analysis (positive/negative/neutral %)
- Video recording URL
- Detailed meeting attendee info
### Configuration
Set your plan in `.env`:
```bash
FIREFLIES_PLAN=free # Options: free, pro, business, enterprise
```
**Rate Limiting:** Free/Pro plans are limited to 50 API calls/day. The integration uses conservative retry settings by default to stay within limits.
## What Gets Captured
### Summary & Insights
- **Action Items** - Concrete next steps and commitments
- **Keywords** - Key topics and themes discussed
- **Overview** - Executive summary of the meeting
- **Topics Discussed** - Main discussion points
- **Meeting Type** - Context (sales call, standup, demo, etc.)
### Analytics
- **Sentiment Analysis** - Positive/negative/neutral percentages for deal health
- **Engagement Metrics** - Participation levels (future)
### Resources
- **Transcript Link** - Quick access to full Fireflies transcript
- **Recording Link** - Video/audio recording when available
## Quick Start
### Installation
```bash
# Step 1: Authenticate with Twenty
npx twenty-cli auth login
# Step 2: Sync the app to create Meeting object
npx twenty-cli app sync packages/twenty-apps/fireflies
# Step 3: Install dependencies
yarn install
# Step 4: Add custom fields
yarn setup:fields
```
(TODO: change when fields setup internal support)
### Configuration
⚠️ **Important**: The integration uses **conservative retry settings** to respect Fireflies' 50 requests/day API limit with free/pro plans. You may increase for more reactivity with higher plans.
**Required Environment Variables:**
```bash
FIREFLIES_API_KEY=your_api_key # From Fireflies settings
TWENTY_API_KEY=your_api_key # From Twenty CRM settings
SERVER_URL=https://your-domain.twenty.com
```
**Optional (Recommended):**
```bash
FIREFLIES_WEBHOOK_SECRET=your_secret # For webhook security
```
📖 **For detailed configuration, troubleshooting, and rate limit management**, see [WEBHOOK_CONFIGURATION.md](./WEBHOOK_CONFIGURATION.md)
### What Gets Created
#### Basic Installation (Step 2)
The `app sync` command creates:
- ✅ Meeting object with basic `name` field
- ✅ Webhook endpoint at `/s/webhook/fireflies`
#### After Custom Fields Setup (Step 4)
The `setup:fields` script adds 13 custom fields to store rich Fireflies data:
| Field Name | Type | Label | Description |
|------------|------|-------|-------------|
| `notes` | RICH_TEXT | Meeting Notes | AI-generated summary with overview, topics, action items, and insights |
| `meetingDate` | DATE_TIME | Meeting Date | Date and time when the meeting occurred |
| `duration` | NUMBER | Duration (minutes) | Meeting duration in minutes |
| `meetingType` | TEXT | Meeting Type | Type of meeting (e.g., Sales Call, Sprint Planning, 1:1) |
| `keywords` | TEXT | Keywords | Key topics and themes discussed (comma-separated) |
| `sentimentScore` | NUMBER | Sentiment Score | Overall meeting sentiment (0-1 scale, 1 = most positive) |
| `positivePercent` | NUMBER | Positive % | Percentage of positive sentiment in conversation |
| `negativePercent` | NUMBER | Negative % | Percentage of negative sentiment in conversation |
| `actionItemsCount` | NUMBER | Action Items | Number of action items identified |
| `transcriptUrl` | LINKS | Transcript URL | Link to full transcript in Fireflies |
| `recordingUrl` | LINKS | Recording URL | Link to video/audio recording in Fireflies |
| `firefliesMeetingId` | TEXT | Fireflies Meeting ID | Unique identifier from Fireflies |
| `organizerEmail` | TEXT | Organizer Email | Email address of the meeting organizer |
**Note:** Without custom fields, meetings will be created with just the title. The rich summary data will only be stored in Notes for 1-on-1 meetings.
## Configuration
### Required Environment Variables
Check [.env.example](./.env.example)
### Summary Processing Strategies
| Strategy | Description | Use Case |
|----------|-------------|----------|
| `immediate_only` | Single fetch attempt, no retries | Fast processing, accept missing summaries if not ready |
| `immediate_with_retry` | Attempts immediate fetch, retries with backoff | **Recommended** - Balances speed and reliability |
| `delayed_polling` | Schedules background polling | For heavily loaded systems |
| `basic_only` | Creates records without waiting for summaries | For basic transcript archival only |
## Webhook Setup
### Step 1: Get Your Webhook URL
Your webhook endpoint will be:
```
https://your-twenty-instance.com/s/webhook/fireflies
```
### Step 2: Configure Fireflies Webhook
1. Log into Fireflies.ai
2. https://app.fireflies.ai/settings#DeveloperSettings
4. Enter your webhook URL
5. Set **Secret**: Generate from there and set value of `FIREFLIES_WEBHOOK_SECRET`
6. Save configuration
### Step 3: Verify Webhook
The integration uses **HMAC SHA-256 signature verification**:
- Fireflies sends `x-hub-signature` header
- Twenty verifies signature using your webhook secret
- Invalid signatures are rejected immediately
### Current Platform Limitation (Headers)
- Twenty serverless route triggers currently do **not forward HTTP headers** to functions. Fireflies signatures sent in headers are stripped, so header-based verification does not work in production.
- Workaround: the provided test script also includes the signature inside the payload; the handler falls back to that payload signature. Use this only for testing until header forwarding is supported.
## Utilities for meeting insertion (workarounds)
- Ingest a specific Fireflies meeting into Twenty:
`yarn meeting:ingest <meetingId>` or `MEETING_ID=... yarn meeting:ingest`
- Fetch all/historical Fireflies meetings into Twenty:
`yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer a@x.com] [--participant b@x.com] [--channel <channelId>] [--mine] [--dry-run]`
- Filters (combine as needed):
- `--from` / `--to`: ISO or date string range filter
- `--organizer` / `--participant`: comma-separated emails
- `--channel`: Fireflies channel id
- `--mine`: only meetings for the current Fireflies user
- Controls:
- `--dry-run`: list and transform without writing to Twenty
- `--page-size`: pagination size (default 50)
- `--max-records`: stop after N transcripts (default 500)
## Development
```bash
# Run tests
npm test
# Run tests in watch mode
npm run test -- --watch
# Development mode with live sync
npx twenty-cli app dev
# Type checking
npx tsgo --noEmit
```
## Testing
The integration includes comprehensive test coverage:
```bash
# Run all tests
npm test
# Run specific test suite
npm test -- fireflies-webhook.spec.ts
# Run with coverage
npm test -- --coverage
```
### Test Coverage
- HMAC signature verification
- Fireflies GraphQL API integration
- Summary processing with retry logic
- Summary-focused CRM record creation
- One-on-one vs multi-party meeting detection
- Contact matching and creation
- Duplicate prevention
- Error handling and resilience
## CRM Record Structure
### One-on-One Meeting Note Example
```markdown
# Meeting: Product Demo with Client (Sales Call)
**Date:** Monday, November 2, 2024, 02:00 PM
**Duration:** 30 minutes
**Participants:** Sarah Sales, John Client
## Overview
Successful product demonstration with positive client feedback.
Client expressed strong interest in the enterprise plan.
## Key Topics
- product features
- pricing discussion
- integration capabilities
- support options
## Action Items
- Follow up with pricing proposal by Friday
- Schedule technical deep-dive next week
- Share case studies from similar clients
## Insights
**Keywords:** product demo, pricing, technical requirements, integration
**Sentiment:** 75% positive, 10% negative, 15% neutral
**Meeting Type:** Sales Call
## Resources
[View Full Transcript](https://app.fireflies.ai/transcript/xxx)
[Watch Recording](https://app.fireflies.ai/recording/xxx)
```
### Multi-Party Meeting Record
- Meeting object with title, date, and all attendees
- Summary stored as meeting notes (structure same as above)
- Action items potentially converted to separate tasks (future)
- Keywords as tags/categories (future)
## Future Implementation Opportunities
Next iterations would enhance the **intelligence layer** to:
### AI-Powered Insights
- **Extract pain points, objections & buying signals** automatically from transcripts
- **Calculate deal health scores** based on conversation sentiment trends
- **Auto-create contextualized tasks** with AI-suggested next steps and priorities
- **Proactively flag at-risk deals** when negative signals appear
- **Track conversation patterns** that correlate with deal success
### Enhanced Analytics
- **Action item completion tracking** across deals
- **Sentiment trend analysis** over time for account health
- **Speaking time analysis** for meeting engagement insights
- **Topic clustering** for product/feature interest patterns
### Workflow Automation
- **Auto-assign follow-up tasks** based on action items
- **Smart notifications** for urgent follow-ups
- **Deal stage progression** based on meeting outcomes
- **Competitive intelligence** extraction from conversations
**Integration**: Fireflies webhook → AI processing layer → Enhanced Twenty records
*This would require the current MVP to be stabilized and discussions about intelligence layer architecture and data privacy considerations.*
@@ -0,0 +1,81 @@
import { type ApplicationConfig } from 'twenty-sdk/define';
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,
value: '',
},
FIREFLIES_API_KEY: {
universalIdentifier: 'faa41f07-b28e-4500-b1c0-ce4b3d27924c',
description: 'Fireflies GraphQL API key used to fetch meeting summaries',
//isSecret: true,
value: '',
},
FIREFLIES_PLAN: {
universalIdentifier: '57dbb73c-aac5-4247-9fcc-a070bb669f16',
description: 'Fireflies plan: free, pro, business, enterprise',
value: 'free',
},
TWENTY_API_KEY: {
universalIdentifier: '02756551-5bf7-4fb2-8e08-1f622008d305',
description: 'Twenty API key used when running scripts locally',
//isSecret: true,
value: '',
},
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',
},
CAPTURE_LOGS: {
universalIdentifier: 'adbcc267-309d-49b2-af71-76f1299d863e',
description: 'Capture logs in webhook response for debugging (true/false)',
value: 'true',
},
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;
@@ -0,0 +1,23 @@
const jestConfig = {
displayName: 'fireflies',
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'js'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(test|spec).{js,ts}',
'<rootDir>/src/**/?(*.)(test|spec).{js,ts}',
],
setupFilesAfterEnv: [
'<rootDir>/src/__tests__/setup.ts'
],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
],
coverageDirectory: './coverage',
};
export default jestConfig;
@@ -0,0 +1,32 @@
{
"name": "fireflies",
"version": "0.3.0",
"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",
"meeting:ingest": "tsx scripts/ingest-meeting.ts",
"meeting:delete": "tsx scripts/delete-meeting.ts",
"meeting:all": "tsx scripts/fetch-all-meetings.ts"
},
"dependencies": {
"axios": "^1.13.1",
"dotenv": "^17.2.3",
"twenty-sdk": "0.1.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"
}
}
@@ -0,0 +1,31 @@
{
"name": "fireflies",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-apps/community/fireflies/src",
"projectType": "application",
"tags": [
"scope:apps"
],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": [
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "packages/twenty-apps/community/fireflies/jest.config.mjs",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"coverageReporters": ["text"]
}
}
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {}
}
}
@@ -0,0 +1,494 @@
/**
* Migration script to add custom fields to the Meeting object
* Run this after: npx twenty-cli app sync packages/twenty-apps/fireflies
*
* Usage: yarn setup:fields
*/
/* oxlint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load environment variables
dotenv.config({ path: path.join(__dirname, '../.env') });
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
const API_KEY = process.env.TWENTY_API_KEY;
if (!API_KEY) {
console.error('❌ Error: TWENTY_API_KEY not found in .env file');
process.exit(1);
}
interface RelationCreationPayload {
targetObjectMetadataId: string;
targetFieldLabel: string;
targetFieldIcon: string;
type: 'ONE_TO_MANY' | 'MANY_TO_ONE';
}
interface FieldOption {
value: string;
label: string;
position: number;
color: string;
}
interface FieldDefinition {
type: string;
name: string;
label: string;
description: string;
icon?: string;
isNullable?: boolean;
relationCreationPayload?: RelationCreationPayload;
options?: FieldOption[];
}
// Meeting fields based on Fireflies GraphQL API transcript schema
// See: https://docs.fireflies.ai/graphql-api/query/transcript
// Note: Some fields require higher plans (Pro, Business, Enterprise)
const MEETING_FIELDS: FieldDefinition[] = [
// === Internal Twenty Relations ===
{
type: 'RELATION',
name: 'note',
label: 'Meeting Note',
description: 'Related note with detailed meeting content',
icon: 'IconNotes',
isNullable: true,
},
// === Basic Fields (All Plans) ===
{
type: 'TEXT',
name: 'firefliesMeetingId',
label: 'Fireflies ID',
description: 'Unique transcript ID from Fireflies (maps to: id)',
icon: 'IconKey',
isNullable: true,
},
{
type: 'DATE_TIME',
name: 'meetingDate',
label: 'Meeting Date',
description: 'When the meeting occurred (maps to: date)',
icon: 'IconCalendar',
isNullable: true,
},
{
type: 'NUMBER',
name: 'duration',
label: 'Duration (minutes)',
description: 'Meeting duration in minutes (maps to: duration)',
icon: 'IconClock',
isNullable: true,
},
{
type: 'TEXT',
name: 'organizerEmail',
label: 'Organizer Email',
description: 'Meeting organizer email (maps to: organizer_email)',
icon: 'IconMail',
isNullable: true,
},
{
type: 'LINKS',
name: 'transcriptUrl',
label: 'Transcript URL',
description: 'Link to full transcript (maps to: transcript_url)',
icon: 'IconFileText',
isNullable: true,
},
{
type: 'LINKS',
name: 'meetingLink',
label: 'Meeting Link',
description: 'Original meeting link (maps to: meeting_link)',
icon: 'IconLink',
isNullable: true,
},
// === Pro+ Fields (summary, speakers, audio_url, transcript) ===
{
type: 'TEXT',
name: 'transcript',
label: 'Full Transcript',
description: 'Full meeting transcript with speaker names and timestamps [Pro+]',
icon: 'IconFileText',
isNullable: true,
},
{
type: 'TEXT',
name: 'overview',
label: 'Overview',
description: 'AI-generated meeting summary (maps to: summary.overview) [Pro+]',
icon: 'IconFileDescription',
isNullable: true,
},
{
type: 'TEXT',
name: 'notes',
label: 'AI Notes',
description: 'Detailed AI-generated meeting notes (maps to: summary.notes) [Pro+]',
icon: 'IconNotes',
isNullable: true,
},
{
type: 'TEXT',
name: 'keywords',
label: 'Keywords',
description: 'Key topics extracted (maps to: summary.keywords) [Pro+]',
icon: 'IconTags',
isNullable: true,
},
{
type: 'LINKS',
name: 'audioUrl',
label: 'Audio URL',
description: 'Link to audio recording (maps to: audio_url) [Pro+]',
icon: 'IconHeadphones',
isNullable: true,
},
// === Business+ Fields (analytics, video_url, full summary) ===
{
type: 'TEXT',
name: 'meetingType',
label: 'Meeting Type',
description: 'AI-detected meeting type (maps to: summary.meeting_type) [Business+]',
icon: 'IconTag',
isNullable: true,
},
{
type: 'TEXT',
name: 'topics',
label: 'Topics Discussed',
description: 'Topics covered in meeting (maps to: summary.topics_discussed) [Business+]',
icon: 'IconListDetails',
isNullable: true,
},
{
type: 'NUMBER',
name: 'actionItemsCount',
label: 'Action Items',
description: 'Number of action items (count of: summary.action_items) [Business+]',
icon: 'IconCheckbox',
isNullable: true,
},
{
type: 'NUMBER',
name: 'positivePercent',
label: 'Positive %',
description: 'Positive sentiment % (maps to: analytics.sentiments.positive_pct) [Business+]',
icon: 'IconThumbUp',
isNullable: true,
},
{
type: 'NUMBER',
name: 'negativePercent',
label: 'Negative %',
description: 'Negative sentiment % (maps to: analytics.sentiments.negative_pct) [Business+]',
icon: 'IconThumbDown',
isNullable: true,
},
{
type: 'NUMBER',
name: 'neutralPercent',
label: 'Neutral %',
description: 'Neutral sentiment % (maps to: analytics.sentiments.neutral_pct) [Business+]',
icon: 'IconMoodNeutral',
isNullable: true,
},
{
type: 'LINKS',
name: 'videoUrl',
label: 'Video URL',
description: 'Link to video recording (maps to: video_url) [Business+]',
icon: 'IconVideo',
isNullable: true,
},
// === Import Tracking Fields (Internal) ===
{
type: 'SELECT',
name: 'importStatus',
label: 'Import Status',
description: 'Status of the Fireflies import',
icon: 'IconCheck',
isNullable: true,
options: [
{ value: 'SUCCESS', label: 'Success', position: 0, color: 'green' },
{ value: 'PARTIAL', label: 'Partial', position: 1, color: 'blue' },
{ value: 'FAILED', label: 'Failed', position: 2, color: 'red' },
{ value: 'PENDING', label: 'Pending', position: 3, color: 'yellow' },
{ value: 'RETRYING', label: 'Retrying', position: 4, color: 'orange' },
],
},
{
type: 'TEXT',
name: 'importError',
label: 'Import Error',
description: 'Error message if import failed',
icon: 'IconAlertTriangle',
isNullable: true,
},
{
type: 'DATE_TIME',
name: 'lastImportAttempt',
label: 'Last Import Attempt',
description: 'When import was last attempted',
icon: 'IconClock',
isNullable: true,
},
{
type: 'NUMBER',
name: 'importAttempts',
label: 'Import Attempts',
description: 'Number of import attempts',
icon: 'IconRepeat',
isNullable: true,
},
];
const graphqlRequest = async (query: string, variables: Record<string, unknown> = {}) => {
const response = await fetch(`${SERVER_URL}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`GraphQL request failed (${response.status}): ${errorText}`);
}
const json = await response.json();
if (json.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(json.errors, null, 2)}`);
}
return json.data;
};
const findMeetingObject = async () => {
const query = `
query FindMeetingObject {
objects(paging: { first: 200 }) {
edges {
node {
id
nameSingular
labelSingular
labelPlural
fields {
edges {
node {
id
name
label
type
}
}
}
}
}
}
}
`;
const data = await graphqlRequest(query);
const edges = data.objects?.edges || [];
const meetingEdge = edges.find(
(edge: any) => edge?.node?.nameSingular === 'meeting',
);
if (!meetingEdge) {
throw new Error('Meeting object not found. Please run "npx twenty-cli app sync" first.');
}
return meetingEdge.node;
};
const findNoteObject = async () => {
const query = `
query FindObjects {
objects(paging: { first: 100 }) {
edges {
node {
id
nameSingular
labelSingular
}
}
}
}
`;
const data = await graphqlRequest(query);
const edges = data.objects?.edges || [];
const noteEdge = edges.find(
(edge: any) => edge?.node?.nameSingular === 'note',
);
if (!noteEdge) {
throw new Error('Note object not found.');
}
return noteEdge.node;
};
const createField = async (objectId: string, field: FieldDefinition) => {
const mutation = `
mutation CreateField($input: CreateOneFieldMetadataInput!) {
createOneField(input: $input) {
id
name
label
type
description
}
}
`;
const input = {
field: {
type: field.type,
name: field.name,
label: field.label,
description: field.description,
icon: field.icon || 'IconAbc',
isNullable: field.isNullable !== false,
isActive: true,
isCustom: true,
objectMetadataId: objectId,
...(field.relationCreationPayload && {
relationCreationPayload: field.relationCreationPayload,
}),
...(field.options && {
options: field.options,
}),
},
};
try {
const data = await graphqlRequest(mutation, { input });
return data.createOneField;
} catch (error) {
if (error instanceof Error) {
const message = error.message;
if (
message.includes('already exists') ||
message.includes('not available') ||
message.includes('Duplicating')
) {
return null;
}
}
throw error;
}
};
const main = async () => {
console.log('🚀 Adding custom fields to Meeting object...\n');
try {
// Step 1: Find Meeting and Note objects
console.log('📋 Finding Meeting object...');
const meetingObject = await findMeetingObject();
console.log(`✅ Found Meeting object: ${meetingObject.labelSingular ?? meetingObject.nameSingular ?? 'Meeting'} (ID: ${meetingObject.id})\n`);
console.log('📋 Finding Note object...');
const noteObject = await findNoteObject();
console.log(`✅ Found Note object: ${noteObject.labelSingular ?? noteObject.nameSingular ?? 'Note'} (ID: ${noteObject.id})\n`);
// Step 2: Update note field with relationCreationPayload
const fieldsToCreate = MEETING_FIELDS.map(field => {
if (field.name === 'note' && field.type === 'RELATION') {
return {
...field,
relationCreationPayload: {
targetObjectMetadataId: noteObject.id,
targetFieldLabel: 'Meeting',
targetFieldIcon: 'IconCalendarEvent',
type: 'MANY_TO_ONE' as const,
},
};
}
return field;
});
// Step 3: Check existing fields
const existingFields = meetingObject.fields?.edges?.map((edge: any) => edge.node.name) || [];
console.log(`📌 Existing fields: ${existingFields.join(', ')}\n`);
// Step 4: Create custom fields
console.log(' Creating custom fields...\n');
let createdCount = 0;
let failedCount = 0;
let skippedCount = 0;
for (const field of fieldsToCreate) {
try {
if (existingFields.includes(field.name)) {
console.log(` ⏭️ ${field.name} - already exists`);
skippedCount++;
continue;
}
const result = await createField(meetingObject.id, field);
if (result) {
console.log(`${field.name} - created successfully`);
createdCount++;
} else {
console.log(` ⏭️ ${field.name} - skipped (already exists)`);
skippedCount++;
}
} catch (error) {
console.error(`${field.name} - failed: ${error instanceof Error ? error.message : String(error)}`);
failedCount++;
}
}
// Step 4: Summary
console.log('\n' + '='.repeat(60));
console.log('📊 Summary:');
console.log(` ✅ Created: ${createdCount} fields`);
console.log(` ⏭️ Skipped: ${skippedCount} fields`);
console.log(` ❌ Failed: ${failedCount} fields`);
console.log('='.repeat(60));
if (failedCount > 0) {
console.log('\n⚠️ Some fields failed to create. Please check the errors above.');
process.exit(1);
}
if (createdCount === 0 && skippedCount === MEETING_FIELDS.length) {
console.log('\n✨ All fields already exist. Nothing to do!\n');
} else if (createdCount > 0) {
console.log('\n✨ Custom fields added successfully!\n');
}
} catch (error) {
console.error('\n❌ Error:', error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// Run the script
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
@@ -0,0 +1,81 @@
/* oxlint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.join(__dirname, '../.env') });
const FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY;
const meetingId = process.argv[2] || '01KBMR1ZYQ34YP8D2KB4B16QPH';
const main = async (): Promise<void> => {
if (!FIREFLIES_API_KEY) {
console.error('❌ FIREFLIES_API_KEY is required');
process.exit(1);
}
const query = `
query GetTranscript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
summary {
overview
notes
gist
bullet_gist
short_summary
short_overview
outline
shorthand_bullet
action_items
keywords
topics_discussed
meeting_type
transcript_chapters
}
}
}
`;
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${FIREFLIES_API_KEY}`,
},
body: JSON.stringify({ query, variables: { transcriptId: meetingId } }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ API request failed with status ${response.status}`);
console.error(errorText);
process.exit(1);
}
const json = await response.json();
console.log('=== Fireflies API Response ===\n');
console.log(JSON.stringify(json, null, 2));
if (json.data?.transcript?.summary) {
const s = json.data.transcript.summary;
console.log('\n=== Summary Fields Status ===');
console.log('overview:', s.overview ? `✓ (${s.overview.length} chars)` : '✗ empty');
console.log('notes:', s.notes ? `✓ (${s.notes.length} chars)` : '✗ empty');
console.log('gist:', s.gist ? `✓ (${s.gist.length} chars)` : '✗ empty');
console.log('bullet_gist:', s.bullet_gist ? `✓ (${s.bullet_gist.length} chars)` : '✗ empty');
console.log('outline:', s.outline ? `✓ (${s.outline.length} chars)` : '✗ empty');
console.log('action_items:', s.action_items?.length || 0, 'items');
console.log('topics_discussed:', s.topics_discussed?.length || 0, 'topics');
console.log('keywords:', s.keywords?.length || 0, 'keywords');
}
};
main().catch((error) => {
console.error('❌ Failed to fetch meeting');
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,59 @@
/* oxlint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.join(__dirname, '../.env') });
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
const API_KEY = process.env.TWENTY_API_KEY;
const meetingId = process.argv[2];
const main = async (): Promise<void> => {
if (!API_KEY) {
console.error('❌ TWENTY_API_KEY is required');
process.exit(1);
}
if (!meetingId) {
console.error('Usage: yarn delete:meeting <meetingId>');
process.exit(1);
}
const response = await fetch(`${SERVER_URL}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
query: `mutation DeleteMeeting($id: UUID!) { deleteMeeting(id: $id) { id } }`,
variables: { id: meetingId },
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Delete failed (status ${response.status})`);
console.error(errorText);
process.exit(1);
}
const result = await response.json();
const deletedId = result.data?.deleteMeeting?.id;
if (result.errors || !deletedId) {
const message = result.errors?.[0]?.message || 'deleteMeeting returned null';
console.error('❌ Error:', message);
process.exit(1);
}
console.log('✅ Deleted meeting:', deletedId);
};
main().catch((error) => {
console.error('❌ Failed to delete meeting');
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,221 @@
/* oxlint-disable no-console */
/**
* Fetch historical Fireflies meetings and insert into Twenty.
*
* Usage:
* yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer alice@x.com] [--participant bob@x.com] [--channel <channelId>] [--mine] [--dry-run] [--page-size 50] [--max-records 200]
*
* Required env:
* FIREFLIES_API_KEY
* TWENTY_API_KEY
*
* Optional env:
* SERVER_URL (defaults to http://localhost:3000)
* FIREFLIES_PLAN (free|pro|business|enterprise)
* AUTO_CREATE_CONTACTS (true|false)
* FIREFLIES_* retry settings (see README)
*/
import * as dotenv from 'dotenv';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { FirefliesApiClient } from '../src/fireflies-api-client';
import { type HistoricalImportFilters, HistoricalImporter } from '../src/historical-importer';
import { createLogger } from '../src/logger';
import { TwentyCrmService } from '../src/twenty-crm-service';
import {
getApiUrl,
getFirefliesPlan,
getSummaryFetchConfig,
shouldAutoCreateContacts,
} from '../src/utils';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
}
const logger = createLogger('cli:meeting:all');
type CliArgs = {
from?: string;
to?: string;
organizer?: string[];
participant?: string[];
channel?: string;
host?: string;
mine?: boolean;
dryRun?: boolean;
pageSize?: number;
maxRecords?: number;
limit?: number;
};
const parseArgs = (argv: string[]): CliArgs => {
const args: CliArgs = {};
const parseNumberArg = (value?: string): number | undefined => {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? undefined : parsed;
};
for (let i = 0; i < argv.length; i += 1) {
const current = argv[i];
const next = argv[i + 1];
switch (current) {
case '--from':
args.from = next;
i += 1;
break;
case '--to':
args.to = next;
i += 1;
break;
case '--organizer':
args.organizer = next ? next.split(',') : [];
i += 1;
break;
case '--participant':
args.participant = next ? next.split(',') : [];
i += 1;
break;
case '--channel':
args.channel = next;
i += 1;
break;
case '--host':
args.host = next;
i += 1;
break;
case '--mine':
args.mine = true;
break;
case '--dry-run':
args.dryRun = true;
break;
case '--page-size':
args.pageSize = parseNumberArg(next);
i += 1;
break;
case '--max-records':
args.maxRecords = parseNumberArg(next);
i += 1;
break;
case '--limit':
args.limit = parseNumberArg(next);
i += 1;
break;
default:
break;
}
}
return args;
};
const parseDate = (value?: string): number | undefined => {
if (!value) {
return undefined;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? undefined : parsed;
};
const main = async (): Promise<void> => {
const args = parseArgs(process.argv.slice(2));
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!firefliesApiKey) {
console.error('❌ FIREFLIES_API_KEY is required');
process.exit(1);
}
if (!twentyApiKey) {
console.error('❌ TWENTY_API_KEY is required');
process.exit(1);
}
const fromDate = parseDate(args.from);
const toDate = parseDate(args.to);
const filters: HistoricalImportFilters = {
fromDate,
toDate,
organizers: args.organizer,
participants: args.participant,
channelId: args.channel,
hostEmail: args.host,
mine: args.mine,
limit: args.limit,
pageSize: args.pageSize,
maxRecords: args.maxRecords,
};
const summaryConfig = getSummaryFetchConfig();
const plan = getFirefliesPlan();
const autoCreateContacts = shouldAutoCreateContacts();
logger.info(
`Starting historical import (dryRun=${Boolean(args.dryRun)}, plan=${plan}, pageSize=${filters.pageSize ?? 50})`,
);
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
const importer = new HistoricalImporter(firefliesClient, twentyService);
const result = await importer.run(filters, {
dryRun: args.dryRun,
autoCreateContacts,
summaryConfig,
plan,
});
console.log('✅ Historical import summary:');
const summary = {
dryRun: result.dryRun,
totalListed: result.totalListed,
imported: result.imported,
skippedExisting: result.skippedExisting,
summaryPending: result.summaryPending,
failed: result.failed,
};
console.log(JSON.stringify(summary, null, 2));
if (result.statuses.length > 0) {
console.log('Status by meeting:');
console.table(
result.statuses.map((s) => ({
meetingId: s.meetingId,
title: s.title ?? '',
status: s.status,
reason: s.reason ?? '',
})),
);
}
if (result.failed.length > 0) {
process.exitCode = 1;
}
};
main().catch((error) => {
console.error('❌ Failed to import historical meetings');
if (error instanceof Error) {
console.error(error.message);
if (error.stack) {
console.error(error.stack);
}
} else {
console.error(String(error));
}
process.exit(1);
});
@@ -0,0 +1,93 @@
/* oxlint-disable no-console */
/**
* Fetch a Fireflies meeting by ID and insert it into Twenty using the same path
* as the webhook handler.
*
* Usage:
* yarn meeting:ingest <meetingId>
* Or
* MEETING_ID=... yarn meeting:ingest
*
* Required env:
* FIREFLIES_API_KEY
* FIREFLIES_WEBHOOK_SECRET
* TWENTY_API_KEY
*
* Optional env:
* SERVER_URL (defaults to http://localhost:3000)
* FIREFLIES_PLAN (free|pro|business|enterprise)
*/
import { createHmac } from 'crypto';
import * as dotenv from 'dotenv';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { WebhookHandler } from '../src/webhook-handler';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
}
const args = process.argv.slice(2);
const meetingId = args[0] || process.env.MEETING_ID;
if (!meetingId) {
console.error('❌ meetingId is required (arg or MEETING_ID env)');
process.exit(1);
}
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
if (!firefliesApiKey) {
console.error('❌ FIREFLIES_API_KEY is required');
process.exit(1);
}
if (!twentyApiKey) {
console.error('❌ TWENTY_API_KEY is required');
process.exit(1);
}
if (!webhookSecret) {
console.error('❌ FIREFLIES_WEBHOOK_SECRET is required to generate signature');
process.exit(1);
}
const payload = {
meetingId,
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = `sha256=${createHmac('sha256', webhookSecret)
.update(body, 'utf8')
.digest('hex')}`;
const main = async (): Promise<void> => {
console.log(`🚀 Ingesting meeting ${meetingId} via webhook handler`);
const handler = new WebhookHandler();
const result = await handler.handle(payload, {
'x-hub-signature': signature,
body,
});
console.log('✅ Result:');
console.log(JSON.stringify(result, null, 2));
if (result.errors && result.errors.length > 0) {
process.exitCode = 1;
}
};
main().catch((error) => {
console.error('❌ Failed to ingest meeting');
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,241 @@
/* oxlint-disable no-console */
/**
* Test script for Fireflies webhook against local Twenty instance
*
* Usage:
* yarn test:webhook
* # or
* npx tsx scripts/test-webhook.ts
*
* Prerequisites:
* 1. Twenty server running on http://localhost:3000
* 2. Fireflies app synced: npx twenty-cli app sync
* 3. Custom fields created: yarn setup:fields
* 4. API key configured (get from Settings > Developers > API Keys)
* 5. Environment variables set (copy .env.example to .env and fill values)
*/
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load environment variables
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
} else {
console.warn('⚠️ .env file not found, using environment variables');
}
// Configuration
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY;
const FIREFLIES_WEBHOOK_SECRET = process.env.FIREFLIES_WEBHOOK_SECRET || 'test_secret';
const _FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY || 'test_api_key';
// Test meeting data (simulating Fireflies API response)
const TEST_MEETING_ID = process.env.MEETING_ID || 'test-meeting-local-' + Date.now();
const CLIENT_REFERENCE_ID = process.env.CLIENT_REFERENCE_ID;
const TEST_WEBHOOK_PAYLOAD = {
meetingId: TEST_MEETING_ID,
eventType: 'Transcription completed',
...(CLIENT_REFERENCE_ID ? { clientReferenceId: CLIENT_REFERENCE_ID } : {}),
};
// Mock Fireflies GraphQL API response
const MOCK_FIREFLIES_RESPONSE = {
data: {
meeting: {
id: TEST_MEETING_ID,
title: 'Local Test Meeting',
date: new Date().toISOString(),
duration: 1800, // 30 minutes
participants: [
{ email: 'test1@example.com', name: 'Test User One' },
{ email: 'test2@example.com', name: 'Test User Two' },
],
organizer_email: 'organizer@example.com',
summary: {
action_items: ['Complete integration testing', 'Review webhook logs'],
keywords: ['testing', 'integration', 'webhook'],
overview: 'This is a test meeting to verify the Fireflies webhook integration.',
gist: 'Quick test summary',
topics_discussed: ['Webhook testing', 'Integration verification'],
meeting_type: 'Test',
},
analytics: {
sentiments: {
positive_pct: 75,
negative_pct: 5,
neutral_pct: 20,
},
},
transcript_url: 'https://app.fireflies.ai/transcript/' + TEST_MEETING_ID,
recording_url: 'https://app.fireflies.ai/recording/' + TEST_MEETING_ID,
summary_status: 'ready',
},
},
};
// Generate HMAC signature
const generateHMACSignature = (body: string, secret: string): string => {
const signature = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
return `sha256=${signature}`;
};
// Mock Fireflies API fetch (currently unused but kept for reference)
// In production, you'd need to mock this at the network level
const _mockFirefliesFetch = async (url: string, options?: RequestInit) => {
if (url.includes('graphql.fireflies.ai')) {
// Return mock Fireflies API response
return new Response(JSON.stringify(MOCK_FIREFLIES_RESPONSE), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// For Twenty API calls, use real fetch
return fetch(url, options);
};
const main = async () => {
console.log('🧪 Testing Fireflies Webhook Against Local Twenty Instance\n');
console.log(`📍 Server URL: ${SERVER_URL}`);
console.log(`🔑 API Key: ${TWENTY_API_KEY ? '✅ Configured' : '❌ Missing'}`);
console.log(`🔐 Webhook Secret: ${FIREFLIES_WEBHOOK_SECRET ? '✅ Configured' : '⚠️ Using test secret'}\n`);
// Validation
if (!TWENTY_API_KEY) {
console.error('❌ Error: TWENTY_API_KEY is required');
console.error(' Get your API key from: Settings > Developers > API Keys');
process.exit(1);
}
// Prepare webhook payload
const unsignedBody = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
const signature = generateHMACSignature(unsignedBody, FIREFLIES_WEBHOOK_SECRET);
const payloadWithSignature = {
...TEST_WEBHOOK_PAYLOAD,
'x-hub-signature': signature,
};
const body = JSON.stringify(payloadWithSignature);
console.log('📤 Sending webhook payload:');
console.log(JSON.stringify(payloadWithSignature, null, 2));
console.log('\n️ Signature is sent both as header (preferred) and in payload as fallback (headers are not passed to serverless functions)\n');
console.log(`\n🔐 HMAC Signature: ${signature}\n`);
// Check if server is reachable
try {
const healthCheck = await fetch(`${SERVER_URL}/api/health`);
if (!healthCheck.ok) {
throw new Error(`Server health check failed: ${healthCheck.status}`);
}
console.log('✅ Server is reachable\n');
} catch {
console.error(`❌ Cannot reach server at ${SERVER_URL}`);
console.error(' Make sure Twenty is running: cd twenty && yarn dev');
process.exit(1);
}
// Note: In a real test, we'd intercept fetch calls
// For now, we'll make a direct request to the webhook endpoint
// The actual serverless function will call Fireflies API
// This test validates the endpoint is accessible
// Webhook endpoint: The route path from manifest is /webhook/fireflies
// Routes are matched after removing /s/ prefix
// So /s/webhook/fireflies should match the route /webhook/fireflies
const webhookUrl = `${SERVER_URL}/s/webhook/fireflies`;
console.log(`📡 Calling webhook endpoint: ${webhookUrl}\n`);
try {
// Note: This will fail because the serverless function needs to call
// Fireflies API, which we can't easily mock at the endpoint level.
// In development, you might want to set FIREFLIES_API_KEY to a test value
// and mock the Fireflies API endpoint separately.
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TWENTY_API_KEY}`,
'x-hub-signature': signature,
},
body: body,
});
const responseText = await response.text();
let responseData;
try {
responseData = JSON.parse(responseText);
} catch {
responseData = responseText;
}
console.log(`📥 Response Status: ${response.status} ${response.statusText}`);
console.log('📥 Response Body:');
console.log(JSON.stringify(responseData, null, 2));
// Report whether the server appears to have received the header signature
const debugMessages = Array.isArray((responseData as any)?.debug)
? ((responseData as any).debug as string[])
: [];
const headerMissing =
debugMessages.some((msg) => msg.includes('headerKeys=none')) ||
debugMessages.some((msg) => msg.includes('providedSignature=undefined'));
const signatureErrors =
Array.isArray((responseData as any)?.errors) &&
((responseData as any).errors as unknown[]).some(
(err) => typeof err === 'string' && err.toLowerCase().includes('signature'),
);
if (headerMissing) {
console.log(
'\n⚠️ Server did not report any received headers; it may be using payload fallback for signature verification.',
);
} else {
console.log('\n✅ Server reported headers present (header-based signature should be used).');
}
if (signatureErrors) {
console.log('⚠️ Signature was rejected by the server (check webhook secret / payload).');
}
if (response.ok) {
console.log('\n✅ Webhook test completed successfully!');
console.log('\n📋 Next steps:');
console.log(' 1. Check Twenty CRM for new Meeting/Note records');
console.log(' 2. Verify custom fields are populated');
console.log(' 3. Check server logs for any errors');
} else {
console.log('\n⚠️ Webhook returned an error status');
console.log(' This might be expected if Fireflies API key is not configured');
console.log(' or if the meeting data fetch fails.');
}
} catch (error) {
console.error('\n❌ Error calling webhook:');
console.error(error instanceof Error ? error.message : String(error));
console.error('\n💡 Troubleshooting:');
console.error(' 1. Ensure Twenty server is running');
console.error(' 2. Ensure app is synced: npx twenty-cli app sync');
console.error(' 3. Check API key is valid');
console.error(' 4. Verify webhook endpoint exists');
process.exit(1);
}
};
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
@@ -0,0 +1,11 @@
// Serverless function entry point - re-exports from src/lib
export { config, main } from '../../../src';
export type {
FirefliesMeetingData,
FirefliesParticipant,
FirefliesWebhookPayload,
ProcessResult,
SummaryFetchConfig,
SummaryStrategy
} from '../../../src';
@@ -0,0 +1,718 @@
import * as crypto from 'crypto';
import {
main,
type FirefliesMeetingData,
type FirefliesWebhookPayload,
} from '../';
// Helper to generate HMAC signature
const generateHMACSignature = (body: string, secret: string): string => {
const signature = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
return `sha256=${signature}`;
};
// Mock raw Fireflies API response with full summary (before transformation)
const mockFirefliesApiResponseWithSummary = {
id: 'test-meeting-001',
title: 'Product Demo with Client',
date: '2024-11-02T14:00:00Z',
duration: 1800,
participants: [
'Sarah Sales <sales@company.com>',
'John Client <client@customer.com>',
],
organizer_email: 'sales@company.com',
summary: {
action_items: [
'Follow up with pricing proposal by Friday',
'Schedule technical deep-dive next week',
'Share case studies from similar clients',
],
keywords: ['product demo', 'pricing', 'technical requirements', 'integration'],
overview: 'Successful product demonstration with positive client feedback. Client expressed strong interest in the enterprise plan and requested technical documentation for their IT team.',
gist: 'Product demo went well, client interested in enterprise plan, next steps identified',
topics_discussed: ['product features', 'pricing discussion', 'integration capabilities', 'support options'],
meeting_type: 'Sales Call',
bullet_gist: '• Demonstrated core product features\n• Discussed enterprise pricing\n• Addressed integration questions',
},
analytics: {
sentiments: {
positive_pct: 75,
negative_pct: 10,
neutral_pct: 15,
},
},
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
video_url: 'https://app.fireflies.ai/recording/test-001',
summary_status: 'completed',
};
// Transformed meeting data (after fetchFirefliesMeetingData processes it)
const mockMeetingWithFullSummary: FirefliesMeetingData = {
id: 'test-meeting-001',
title: 'Product Demo with Client',
date: '2024-11-02T14:00:00Z',
duration: 1800,
participants: [
{ email: 'sales@company.com', name: 'Sarah Sales' },
{ email: 'client@customer.com', name: 'John Client' },
],
organizer_email: 'sales@company.com',
summary: {
action_items: [
'Follow up with pricing proposal by Friday',
'Schedule technical deep-dive next week',
'Share case studies from similar clients',
],
keywords: ['product demo', 'pricing', 'technical requirements', 'integration'],
overview: 'Successful product demonstration with positive client feedback. Client expressed strong interest in the enterprise plan and requested technical documentation for their IT team.',
gist: 'Product demo went well, client interested in enterprise plan, next steps identified',
topics_discussed: ['product features', 'pricing discussion', 'integration capabilities', 'support options'],
meeting_type: 'Sales Call',
bullet_gist: '• Demonstrated core product features\n• Discussed enterprise pricing\n• Addressed integration questions',
},
analytics: {
sentiments: {
positive_pct: 75,
negative_pct: 10,
neutral_pct: 15,
},
},
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
video_url: 'https://app.fireflies.ai/recording/test-001',
summary_status: 'completed',
};
// Mock raw API response without summary (processing)
const mockFirefliesApiResponseWithoutSummary = {
id: 'test-meeting-002',
title: 'Team Standup',
date: '2024-11-02T15:00:00Z',
duration: 900,
participants: [
'Alice Developer <dev1@company.com>',
'Bob Developer <dev2@company.com>',
],
organizer_email: 'dev1@company.com',
summary: {
action_items: [],
keywords: [],
overview: '',
gist: '',
topics_discussed: [],
},
transcript_url: 'https://app.fireflies.ai/transcript/test-002',
summary_status: 'processing',
};
// Mock meeting data without summary (processing) - currently unused but kept for reference
const _mockMeetingWithoutSummary = {
id: 'test-meeting-002',
title: 'Team Standup',
date: '2024-11-02T15:00:00Z',
duration: 900,
participants: [
{ email: 'dev1@company.com', name: 'Alice Developer' },
{ email: 'dev2@company.com', name: 'Bob Developer' },
],
organizer_email: 'dev1@company.com',
summary: {
action_items: [],
keywords: [],
overview: '',
gist: '',
topics_discussed: [],
},
transcript_url: 'https://app.fireflies.ai/transcript/test-002',
summary_status: 'processing',
};
// Mock raw API response for team meeting
const mockFirefliesApiResponseTeamMeeting = {
...mockFirefliesApiResponseWithSummary,
id: 'test-team-003',
title: 'Sprint Planning',
participants: [
'Alice Scrum <scrum@company.com>',
'Bob Developer <dev1@company.com>',
'Carol Coder <dev2@company.com>',
'David QA <qa@company.com>',
],
summary: {
...mockFirefliesApiResponseWithSummary.summary,
meeting_type: 'Sprint Planning',
},
};
// Mock team meeting with multiple participants (transformed) - currently unused but kept for reference
const _mockTeamMeeting = {
...mockMeetingWithFullSummary,
id: 'test-team-003',
title: 'Sprint Planning',
participants: [
{ email: 'scrum@company.com', name: 'Alice Scrum' },
{ email: 'dev1@company.com', name: 'Bob Developer' },
{ email: 'dev2@company.com', name: 'Carol Coder' },
{ email: 'qa@company.com', name: 'David QA' },
],
summary: {
...mockMeetingWithFullSummary.summary,
meeting_type: 'Sprint Planning',
},
};
// Mock environment variables
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
FIREFLIES_WEBHOOK_SECRET: 'test_webhook_secret',
FIREFLIES_API_KEY: 'test_fireflies_api_key',
TWENTY_API_KEY: 'test_twenty_api_key',
SERVER_URL: 'http://localhost:3000',
AUTO_CREATE_CONTACTS: 'true',
DEBUG_LOGS: 'false',
FIREFLIES_SUMMARY_STRATEGY: 'immediate_with_retry',
FIREFLIES_RETRY_ATTEMPTS: '3',
FIREFLIES_RETRY_DELAY: '1000',
};
});
afterEach(() => {
process.env = originalEnv;
jest.clearAllMocks();
});
describe('Fireflies Webhook Integration v2', () => {
describe('Webhook Authentication', () => {
it('should verify HMAC SHA-256 signature from x-hub-signature header', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
// Mock Fireflies API
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
transcript: mockFirefliesApiResponseWithSummary, // Use raw API format
},
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
});
it('should reject requests with invalid signature', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const invalidSignature = 'sha256=invalid_signature_here';
const result = await main(payload, { 'x-hub-signature': invalidSignature, body });
expect(result.success).toBe(false);
expect(result.errors).toContain('Invalid webhook signature');
});
it('should reject requests without signature header', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const result = await main(payload, {});
expect(result.success).toBe(false);
expect(result.errors).toContain('Invalid webhook signature');
});
it('should reject requests with missing webhook secret env var', async () => {
delete process.env.FIREFLIES_WEBHOOK_SECRET;
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const result = await main(payload, {});
expect(result.success).toBe(false);
});
});
describe('Fireflies GraphQL Integration', () => {
it('should fetch meeting data from Fireflies API', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
const firefliesApiMock = jest.fn();
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
firefliesApiMock();
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
transcript: mockFirefliesApiResponseWithSummary, // Use raw API format
},
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(firefliesApiMock).toHaveBeenCalled();
expect(result.success).toBe(true);
});
it('should handle Fireflies API fetch failures gracefully', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.reject(new Error('Fireflies API unavailable'));
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ data: {} }) });
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Fireflies API');
});
it('should handle malformed GraphQL responses', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { malformed: 'response' },
}),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ data: {} }) });
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Invalid response from Fireflies API');
});
});
describe('Summary Processing', () => {
it('should create complete records when summary is ready', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithSummary }, // Use raw API format
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
expect(result.summaryReady).toBe(true);
expect(result.actionItemsCount).toBe(3);
expect(result.sentimentAnalysis).toEqual({
positive_pct: 75,
negative_pct: 10,
neutral_pct: 15,
});
expect(result.meetingType).toBe('Sales Call');
expect(result.keyTopics).toEqual(['product features', 'pricing discussion', 'integration capabilities', 'support options']);
});
it('should create basic records when summary is pending', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-002',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithoutSummary }, // Use raw API format
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
expect(result.summaryPending).toBe(true);
expect(result.noteIds || result.meetingId).toBeDefined();
});
it('should retry summary fetch with exponential backoff', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-003',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
let attemptCount = 0;
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
attemptCount++;
// First two attempts return no summary, third returns full summary
if (attemptCount < 3) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithoutSummary }, // Use raw API format
}),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithSummary }, // Use raw API format
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(attemptCount).toBe(3);
expect(result.success).toBe(true);
expect(result.summaryReady).toBe(true);
});
it('should handle immediate_only strategy with single fetch attempt', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-004',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
let fetchCount = 0;
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
fetchCount++;
// Return summary not ready
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithoutSummary },
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
// Override strategy for this test
process.env.FIREFLIES_SUMMARY_STRATEGY = 'immediate_only';
const result = await main(payload, { 'x-hub-signature': signature, body });
// Should only fetch once with immediate_only strategy
expect(fetchCount).toBe(1);
expect(result.success).toBe(true);
expect(result.summaryPending).toBe(true);
// Reset to default
process.env.FIREFLIES_SUMMARY_STRATEGY = 'immediate_with_retry';
});
});
describe('CRM Record Creation', () => {
it('should create summary-focused notes for 1-on-1 meetings', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
const createNoteMock = jest.fn();
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithSummary }, // Use raw API format
}),
});
}
// Twenty API
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
if (requestBody.query?.includes('createNote')) {
createNoteMock(requestBody.variables);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { createNote: { id: 'new-note-id' } },
}),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
expect(createNoteMock).toHaveBeenCalled();
const noteData = createNoteMock.mock.calls[0][0];
expect(noteData.data.title).toContain('Meeting:');
expect(noteData.data.bodyV2.markdown).toContain('## Overview'); // Markdown header, not bold
expect(noteData.data.bodyV2.markdown).toContain('## Action Items'); // Markdown header, not bold
expect(noteData.data.bodyV2.markdown).toContain('**Sentiment:**'); // This is bold
expect(noteData.data.bodyV2.markdown).toContain('View Full Transcript on Fireflies');
});
it('should create meeting records for multi-party meetings', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-team-003',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
const createMeetingMock = jest.fn();
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseTeamMeeting }, // Use raw API format
}),
});
}
// Twenty API
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
if (requestBody.query?.includes('createMeeting')) {
createMeetingMock(requestBody.variables);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { createMeeting: { id: 'new-meeting-id' } },
}),
});
}
if (requestBody.query?.includes('createNote')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { createNote: { id: 'new-note-id' } },
}),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
expect(result.meetingId).toBeDefined();
expect(createMeetingMock).toHaveBeenCalled();
});
});
describe('Error Handling & Resilience', () => {
it('should never throw uncaught exceptions', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-critical-error',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation(() => {
throw new Error('Critical failure');
});
await expect(main(payload, { 'x-hub-signature': signature, body })).resolves.toEqual(
expect.objectContaining({ success: false, errors: expect.any(Array) })
);
});
it('should handle missing payload gracefully', async () => {
const result = await main(null as unknown, {});
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
it('should handle invalid payload structure', async () => {
const invalidPayload = { invalid: 'data' };
const result = await main(invalidPayload as unknown, {});
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
});
});
@@ -0,0 +1,83 @@
import { HistoricalImporter } from '../historical-importer';
import type { FirefliesMeetingData, SummaryFetchConfig } from '../types';
const summaryConfig: SummaryFetchConfig = {
strategy: 'immediate_with_retry',
retryAttempts: 1,
retryDelay: 0,
pollInterval: 0,
maxPolls: 0,
};
const sampleMeeting: FirefliesMeetingData = {
id: 'm-1',
title: 'Sample',
date: new Date().toISOString(),
duration: 30,
participants: [],
summary: { action_items: [], overview: '' },
transcript_url: 'https://example.com',
};
describe('HistoricalImporter', () => {
const buildImporter = () => {
const firefliesClient = {
listTranscripts: jest.fn(),
fetchMeetingDataWithRetry: jest.fn(),
} as unknown as jest.Mocked<any>;
const twentyService = {
findMeetingByFirefliesId: jest.fn(),
matchParticipantsToContacts: jest.fn(),
createContactsForUnmatched: jest.fn(),
createNoteOnly: jest.fn(),
createMeeting: jest.fn(),
createNoteTarget: jest.fn(),
} as unknown as jest.Mocked<any>;
return { firefliesClient, twentyService };
};
it('skips meetings that already exist by firefliesMeetingId', async () => {
const { firefliesClient, twentyService } = buildImporter();
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'existing' }]);
twentyService.findMeetingByFirefliesId.mockResolvedValue({ id: 'twenty-id' });
const importer = new HistoricalImporter(firefliesClient, twentyService);
const result = await importer.run(
{},
{ dryRun: false, autoCreateContacts: true, summaryConfig, plan: 'free' },
);
expect(result.skippedExisting).toBe(1);
expect(result.imported).toBe(0);
expect(twentyService.createMeeting).not.toHaveBeenCalled();
expect(result.statuses[0].status).toBe('skipped_existing');
});
it('supports dry-run without writing to Twenty', async () => {
const { firefliesClient, twentyService } = buildImporter();
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'm-2' }]);
firefliesClient.fetchMeetingDataWithRetry.mockResolvedValue({
data: sampleMeeting,
summaryReady: false,
});
twentyService.findMeetingByFirefliesId.mockResolvedValue(undefined);
const importer = new HistoricalImporter(firefliesClient, twentyService);
const result = await importer.run(
{},
{ dryRun: true, autoCreateContacts: false, summaryConfig, plan: 'free' },
);
expect(result.imported).toBe(1);
expect(result.summaryPending).toBe(1);
expect(twentyService.createMeeting).not.toHaveBeenCalled();
expect(twentyService.createNoteOnly).not.toHaveBeenCalled();
expect(result.statuses).toHaveLength(1);
expect(result.statuses[0].status).toBe('pending_summary');
});
});
@@ -0,0 +1,17 @@
// Test setup for Fireflies app
// Mock global fetch for all tests
global.fetch = jest.fn();
// Setup test environment variables
process.env.FIREFLIES_WEBHOOK_SECRET = 'testsecret';
process.env.AUTO_CREATE_CONTACTS = 'true';
process.env.SERVER_URL = 'http://localhost:3000';
process.env.TWENTY_API_KEY = 'test-api-key';
process.env.LOG_LEVEL = 'silent';
process.env.CAPTURE_LOGS = 'false';
// Reset mocks before each test
beforeEach(() => {
jest.clearAllMocks();
});
@@ -0,0 +1,40 @@
import { WebhookHandler } from '../webhook-handler';
describe('WebhookHandler log capture', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
FIREFLIES_WEBHOOK_SECRET: 'testsecret',
FIREFLIES_API_KEY: '',
TWENTY_API_KEY: '',
CAPTURE_LOGS: 'false',
LOG_LEVEL: 'silent',
};
});
afterEach(() => {
process.env = originalEnv;
});
it('includes debug logs in response when CAPTURE_LOGS is true', async () => {
process.env.CAPTURE_LOGS = 'true';
const handler = new WebhookHandler();
const result = await handler.handle(null);
expect(result.success).toBe(false);
expect(Array.isArray(result.debug)).toBe(true);
});
it('omits debug logs when CAPTURE_LOGS is false', async () => {
process.env.CAPTURE_LOGS = 'false';
const handler = new WebhookHandler();
const result = await handler.handle(null);
expect(result.debug).toBeUndefined();
});
});
@@ -0,0 +1,823 @@
import { createLogger } from './logger';
import {
FIREFLIES_PLANS,
type FirefliesMeetingData,
type FirefliesParticipant,
type FirefliesPlan,
type FirefliesTranscriptListItem,
type FirefliesTranscriptListOptions,
type SummaryFetchConfig
} from './types';
const logger = createLogger('fireflies-api');
export class FirefliesApiClient {
private apiKey: string;
constructor(apiKey: string) {
if (!apiKey) {
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
throw new Error('FIREFLIES_API_KEY is required');
}
this.apiKey = apiKey;
}
async listTranscripts(options: FirefliesTranscriptListOptions = {}): Promise<FirefliesTranscriptListItem[]> {
const {
organizers,
participants,
hostEmail,
participantEmail,
userId,
channelId,
mine,
fromDate,
toDate,
pageSize = 50,
maxRecords = 500,
} = options;
const sanitizedOrganizers = organizers?.filter(Boolean);
const sanitizedParticipants = participants?.filter(Boolean);
const transcripts: FirefliesTranscriptListItem[] = [];
let skip = options.skip ?? 0;
const limit = options.limit ?? pageSize;
const baseQuery = `
query Transcripts(
$limit: Int
$skip: Int
$hostEmail: String
$participantEmail: String
$organizers: [String!]
$participants: [String!]
$userId: String
$channelId: String
$mine: Boolean
$date: Float
) {
transcripts(
limit: $limit
skip: $skip
host_email: $hostEmail
participant_email: $participantEmail
organizers: $organizers
participants: $participants
user_id: $userId
channel_id: $channelId
mine: $mine
date: $date
) {
id
title
date
duration
organizer_email
participants
transcript_url
meeting_link
meeting_info { summary_status }
}
}
`;
while (transcripts.length < maxRecords) {
const pageVariables = {
limit,
skip,
hostEmail,
participantEmail,
organizers: sanitizedOrganizers,
participants: sanitizedParticipants,
userId,
channelId,
mine,
date: fromDate,
};
const page = await this.executeTranscriptListQuery(baseQuery, pageVariables);
const normalized = page
.map((item) => {
const normalizedDate = this.normalizeDate(item.date);
return {
id: (item.id as string) || '',
title: (item.title as string) || 'Untitled Meeting',
date: normalizedDate,
duration: (item.duration as number) || 0,
organizer_email: item.organizer_email as string | undefined,
participants: Array.isArray(item.participants)
? (item.participants as string[])
: undefined,
transcript_url: item.transcript_url as string | undefined,
meeting_link: item.meeting_link as string | undefined,
summary_status: (item.meeting_info as { summary_status?: string } | undefined)?.summary_status,
};
})
.filter((item) => {
if (toDate && item.date) {
const itemTime = Date.parse(item.date);
if (!Number.isNaN(itemTime) && itemTime > toDate) {
return false;
}
}
return true;
});
transcripts.push(...normalized);
if (page.length < limit) {
break;
}
skip += limit;
}
if (transcripts.length > maxRecords) {
return transcripts.slice(0, maxRecords);
}
return transcripts;
}
async fetchMeetingData(
meetingId: string,
options?: { timeout?: number; plan?: FirefliesPlan }
): Promise<FirefliesMeetingData> {
const plan = options?.plan ?? FIREFLIES_PLANS.FREE;
const isPremiumPlan =
plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE;
// Minimal query for free plans - only basic fields available on all plans
// Note: audio_url requires Pro+, video_url requires Business+
const freeQuery = `
query GetTranscriptMinimal($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
transcript_url
meeting_link
}
}
`;
// Standard query for pro plans - adds speakers, summary, sentences, and audio_url (Pro+)
// Note: video_url requires Business+
const proQuery = `
query GetTranscriptBasic($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
speakers {
name
}
sentences {
index
speaker_name
text
start_time
end_time
}
summary {
overview
keywords
action_items
notes
gist
bullet_gist
short_summary
short_overview
outline
shorthand_bullet
}
meeting_info {
summary_status
}
transcript_url
audio_url
meeting_link
}
}
`;
// Full query for business/enterprise - includes all fields
const businessQuery = `
query GetTranscriptFull($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
analytics {
sentiments {
positive_pct
negative_pct
neutral_pct
}
categories {
questions
tasks
metrics
date_times
}
speakers {
speaker_id
name
duration
word_count
longest_monologue
filler_words
questions
words_per_minute
}
}
meeting_attendees {
displayName
email
phoneNumber
name
location
}
meeting_attendance {
name
join_time
leave_time
}
speakers {
name
}
sentences {
index
speaker_name
text
start_time
end_time
ai_filters {
task
question
sentiment
}
}
summary {
action_items
overview
keywords
notes
gist
bullet_gist
short_summary
short_overview
outline
shorthand_bullet
topics_discussed
meeting_type
transcript_chapters
}
meeting_info {
summary_status
}
transcript_url
audio_url
video_url
meeting_link
}
}
`;
// Select query based on plan
const queryToUse = isPremiumPlan ? businessQuery :
(plan === FIREFLIES_PLANS.PRO ? proQuery : freeQuery);
const planFeatures = {
[FIREFLIES_PLANS.FREE]: 'basic fields only (no summary, no audio/video)',
[FIREFLIES_PLANS.PRO]: 'summary, speakers, audio_url',
[FIREFLIES_PLANS.BUSINESS]: 'full access including analytics, video_url',
[FIREFLIES_PLANS.ENTERPRISE]: 'full access including analytics, video_url',
};
logger.debug(`using ${plan} plan query (${planFeatures[plan]})`);
try {
return await this.executeTranscriptQuery({
meetingId,
query: queryToUse,
timeout: options?.timeout,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Detect plan-specific errors
const requiresBusiness = message.toLowerCase().includes('business or higher');
const requiresPro = message.toLowerCase().includes('pro or higher');
const planError = requiresBusiness || requiresPro ||
message.toLowerCase().includes('higher plan') ||
message.includes('Cannot query field');
// Fallback cascade: business -> pro -> free
if (planError) {
if (isPremiumPlan) {
logger.warn(`Plan limitation detected (configured: ${plan}), falling back to pro query`);
try {
return await this.executeTranscriptQuery({
meetingId,
query: proQuery,
timeout: options?.timeout,
});
} catch (proError) {
const proMessage = proError instanceof Error ? proError.message : String(proError);
if (proMessage.toLowerCase().includes('plan') || proMessage.includes('Cannot query field')) {
logger.warn('Pro query also failed, falling back to minimal free query');
return this.executeTranscriptQuery({
meetingId,
query: freeQuery,
timeout: options?.timeout,
});
}
throw proError;
}
} else if (plan === FIREFLIES_PLANS.PRO) {
logger.warn(`Pro plan query failed (${requiresBusiness ? 'requires Business+' : 'unknown restriction'}), falling back to free query`);
return this.executeTranscriptQuery({
meetingId,
query: freeQuery,
timeout: options?.timeout,
});
} else {
// Already using free query - some field might still be restricted
logger.error(
'Fireflies API rejected the minimal free query. This may indicate: ' +
'1) The transcript ID is invalid, or ' +
'2) Your API key does not have access to this transcript, or ' +
'3) An unexpected API restriction : open an issue'
);
}
}
throw error;
}
}
async fetchMeetingDataWithRetry(
meetingId: string,
config: SummaryFetchConfig,
plan: FirefliesPlan = FIREFLIES_PLANS.FREE
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
// immediate_only: single attempt, no retries
if (config.strategy === 'immediate_only') {
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
const ready = this.isSummaryReady(meetingData);
logger.debug(`summary ready: ${ready}`);
return { data: meetingData, summaryReady: ready };
}
// immediate_with_retry: retry with linear backoff
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try {
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
const ready = this.isSummaryReady(meetingData);
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
if (ready) {
return { data: meetingData, summaryReady: true };
}
if (attempt < config.retryAttempts) {
const delayMs = config.retryDelay * attempt;
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
await new Promise(resolve => setTimeout(resolve, delayMs));
} else {
logger.debug(`max retries reached, returning partial data`);
return { data: meetingData, summaryReady: false };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
if (attempt === config.retryAttempts) {
throw error;
}
const delayMs = config.retryDelay * attempt;
logger.debug(`retrying in ${delayMs}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw new Error('Failed to fetch meeting data after retries');
}
private async executeTranscriptQuery({
meetingId,
query,
timeout,
}: {
meetingId: string;
query: string;
timeout?: number;
}): Promise<FirefliesMeetingData> {
const controller = new AbortController();
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
try {
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
query,
variables: { transcriptId: meetingId },
}),
signal: controller.signal,
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
let errorDetails = `Fireflies API request failed with status ${response.status}`;
try {
const errorBody = await response.text();
if (errorBody) {
errorDetails += `: ${errorBody}`;
}
} catch {
// Ignore if we can't read the response body
}
throw new Error(errorDetails);
}
const json = await response.json() as {
data?: { transcript?: Record<string, unknown> };
errors?: Array<{ message?: string }>;
};
if (json.errors && json.errors.length > 0) {
throw new Error(`Fireflies API error: ${json.errors[0]?.message || 'Unknown error'}`);
}
const transcript = json.data?.transcript;
if (!transcript) {
throw new Error('Invalid response from Fireflies API: missing transcript data');
}
return this.transformMeetingData(transcript, meetingId);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
private isSummaryReady(meetingData: FirefliesMeetingData): boolean {
return (
(meetingData.summary?.action_items?.length > 0) ||
(meetingData.summary?.overview?.length > 0) ||
meetingData.summary_status === 'completed'
);
}
private extractAllParticipants(transcript: Record<string, unknown>): FirefliesParticipant[] {
const participantsWithEmails: FirefliesParticipant[] = [];
const participantsNameOnly: FirefliesParticipant[] = [];
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
logger.debug('participants field:', JSON.stringify(transcript.participants));
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
logger.debug('speakers field:', (transcript.speakers as Array<{ name: string }>)?.map((s) => s.name));
logger.debug('meeting_attendance field:', (transcript.meeting_attendance as Array<{ name: string }>)?.map((a) => a.name));
logger.debug('organizer_email:', transcript.organizer_email);
const isEmail = (str: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
};
const isDuplicate = (name: string, email: string): boolean => {
const nameLower = name.toLowerCase().trim();
const emailLower = email.toLowerCase().trim();
return participantsWithEmails.some(p =>
p.name.toLowerCase().trim() === nameLower ||
(email && p.email.toLowerCase() === emailLower)
) || participantsNameOnly.some(p =>
p.name.toLowerCase().trim() === nameLower
);
};
// 1. Extract from legacy participants field (with emails)
if (transcript.participants && Array.isArray(transcript.participants)) {
transcript.participants.forEach((participant: string) => {
const parts = participant.split(',').map(p => p.trim());
parts.forEach(part => {
const emailMatch = part.match(/<([^>]+)>/);
const email = emailMatch ? emailMatch[1] : '';
const name = emailMatch
? part.substring(0, part.indexOf('<')).trim()
: part.trim();
if (isEmail(name)) {
logger.debug(`Skipping participant with email as name: "${name}"`);
return;
}
if (!name) {
return;
}
if (isDuplicate(name, email)) {
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
return;
}
if (name && email) {
participantsWithEmails.push({ name, email });
} else if (name) {
participantsNameOnly.push({ name, email: '' });
}
});
});
}
// 2. Extract from meeting_attendees field (structured)
if (transcript.meeting_attendees && Array.isArray(transcript.meeting_attendees)) {
transcript.meeting_attendees.forEach((attendee: Record<string, unknown>) => {
const name = (attendee.displayName || attendee.name || '') as string;
const email = (attendee.email || '') as string;
if (isEmail(name)) {
logger.debug(`Skipping attendee with email as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, email)) {
if (email) {
participantsWithEmails.push({ name, email });
} else {
participantsNameOnly.push({ name, email: '' });
}
}
});
}
// 3. Extract from speakers field (name only)
if (transcript.speakers && Array.isArray(transcript.speakers)) {
transcript.speakers.forEach((speaker: Record<string, unknown>) => {
const name = (speaker.name || '') as string;
if (isEmail(name)) {
logger.debug(`Skipping speaker with email as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, '')) {
participantsNameOnly.push({ name, email: '' });
}
});
}
// 4. Extract from meeting_attendance field (name only)
if (transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
transcript.meeting_attendance.forEach((attendance: Record<string, unknown>) => {
const name = (attendance.name || '') as string;
if (isEmail(name) || name.includes(',')) {
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, '')) {
participantsNameOnly.push({ name, email: '' });
}
});
}
// 5. Add organizer email if available and not already included
const organizerEmail = transcript.organizer_email as string | undefined;
if (organizerEmail) {
const existsWithEmail = participantsWithEmails.some(p =>
p.email.toLowerCase() === organizerEmail.toLowerCase()
);
if (!existsWithEmail) {
let organizerName = '';
const emailUsername = organizerEmail.split('@')[0].toLowerCase();
const emailNameVariations = [emailUsername];
if (transcript.speakers && Array.isArray(transcript.speakers)) {
const potentialOrganizerSpeaker = transcript.speakers.find((speaker: Record<string, unknown>) => {
const name = ((speaker.name || '') as string).toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
}) as Record<string, unknown> | undefined;
if (potentialOrganizerSpeaker) {
organizerName = potentialOrganizerSpeaker.name as string;
}
}
if (!organizerName && transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
const potentialOrganizerAttendance = transcript.meeting_attendance.find((attendance: Record<string, unknown>) => {
const name = ((attendance.name || '') as string).toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
}) as Record<string, unknown> | undefined;
if (potentialOrganizerAttendance) {
organizerName = potentialOrganizerAttendance.name as string;
}
}
if (organizerName) {
participantsWithEmails.push({ name: organizerName, email: organizerEmail });
const nameIndex = participantsNameOnly.findIndex(p =>
p.name.toLowerCase().includes(organizerName.toLowerCase()) ||
organizerName.toLowerCase().includes(p.name.toLowerCase())
);
if (nameIndex !== -1) {
participantsNameOnly.splice(nameIndex, 1);
}
} else {
participantsWithEmails.push({ name: 'Meeting Organizer', email: organizerEmail });
}
}
}
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
logger.debug('=== EXTRACTED PARTICIPANTS ===');
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
logger.debug('Total:', allParticipants.length);
return allParticipants;
}
private transformMeetingData(transcript: Record<string, unknown>, meetingId: string): FirefliesMeetingData {
let dateString: string;
if (transcript.date) {
if (typeof transcript.date === 'number') {
dateString = new Date(transcript.date).toISOString();
} else if (typeof transcript.date === 'string') {
const parsed = Number(transcript.date);
if (!isNaN(parsed)) {
dateString = new Date(parsed).toISOString();
} else {
dateString = transcript.date;
}
} else {
dateString = new Date().toISOString();
}
} else {
dateString = new Date().toISOString();
}
const summary = transcript.summary as Record<string, unknown> | undefined;
const analytics = transcript.analytics as Record<string, unknown> | undefined;
const sentiments = analytics?.sentiments as Record<string, number> | undefined;
const categories = analytics?.categories as Record<string, number> | undefined;
const speakersAnalytics = analytics?.speakers as Array<Record<string, unknown>> | undefined;
const meetingInfo = transcript.meeting_info as Record<string, unknown> | undefined;
// Transform sentences array
const rawSentences = transcript.sentences as Array<Record<string, unknown>> | undefined;
const sentences = rawSentences?.map(s => ({
index: (s.index as number) || 0,
speaker_name: (s.speaker_name as string) || 'Unknown',
text: (s.text as string) || '',
start_time: (s.start_time as string) || '0',
end_time: (s.end_time as string) || '0',
ai_filters: s.ai_filters as { task?: boolean; question?: boolean; sentiment?: string } | undefined,
}));
// Transform speaker analytics
const speakers = speakersAnalytics?.map(sp => ({
speaker_id: (sp.speaker_id as string) || '',
name: (sp.name as string) || 'Unknown',
duration: (sp.duration as number) || 0,
word_count: (sp.word_count as number) || 0,
longest_monologue: (sp.longest_monologue as number) || 0,
filler_words: (sp.filler_words as number) || 0,
questions: (sp.questions as number) || 0,
words_per_minute: (sp.words_per_minute as number) || 0,
}));
return {
id: (transcript.id as string) || meetingId,
title: (transcript.title as string) || 'Untitled Meeting',
date: dateString,
duration: (transcript.duration as number) || 0,
participants: this.extractAllParticipants(transcript),
organizer_email: transcript.organizer_email as string | undefined,
sentences,
summary: {
// action_items can be string or array - normalize to array
action_items: Array.isArray(summary?.action_items)
? summary.action_items as string[]
: (typeof summary?.action_items === 'string' && summary.action_items.trim()
? summary.action_items.split('\n').filter((item: string) => item.trim())
: []),
overview: (summary?.overview as string) || '',
notes: summary?.notes as string | undefined,
gist: summary?.gist as string | undefined,
bullet_gist: summary?.bullet_gist as string | undefined,
short_summary: summary?.short_summary as string | undefined,
short_overview: summary?.short_overview as string | undefined,
outline: summary?.outline as string | undefined,
shorthand_bullet: summary?.shorthand_bullet as string | undefined,
keywords: summary?.keywords as string[] | undefined,
topics_discussed: summary?.topics_discussed as string[] | undefined,
meeting_type: summary?.meeting_type as string | undefined,
transcript_chapters: summary?.transcript_chapters as string[] | undefined,
},
analytics: (sentiments || categories || speakers) ? {
sentiments: sentiments ? {
positive_pct: sentiments.positive_pct || 0,
negative_pct: sentiments.negative_pct || 0,
neutral_pct: sentiments.neutral_pct || 0,
} : undefined,
categories: categories ? {
questions: categories.questions || 0,
tasks: categories.tasks || 0,
metrics: categories.metrics || 0,
date_times: categories.date_times || 0,
} : undefined,
speakers,
} : undefined,
meeting_info: meetingInfo ? {
summary_status: meetingInfo.summary_status as string | undefined,
} : undefined,
// URLs by plan availability:
transcript_url: (transcript.transcript_url as string) || `https://app.fireflies.ai/view/${meetingId}`,
audio_url: transcript.audio_url as string | undefined, // Pro+
video_url: transcript.video_url as string | undefined, // Business+
meeting_link: transcript.meeting_link as string | undefined, // All plans
summary_status: (meetingInfo?.summary_status as string) || (transcript.summary_status as string) || undefined,
};
}
private async executeTranscriptListQuery(
query: string,
variables: Record<string, unknown>,
): Promise<Array<Record<string, unknown>>> {
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Fireflies transcripts request failed: ${response.status} ${errorBody}`);
}
const json = await response.json() as {
data?: { transcripts?: Array<Record<string, unknown>> };
errors?: Array<{ message?: string }>;
};
if (json.errors && json.errors.length > 0) {
const message = json.errors[0]?.message || 'Unknown error';
throw new Error(`Fireflies API error: ${message}`);
}
return json.data?.transcripts ?? [];
}
private normalizeDate(dateValue: unknown): string | undefined {
if (!dateValue) {
return undefined;
}
if (typeof dateValue === 'number') {
return new Date(dateValue).toISOString();
}
if (typeof dateValue === 'string') {
const parsed = Number(dateValue);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
return dateValue;
}
return undefined;
}
}
@@ -0,0 +1,259 @@
import type { FirefliesMeetingData, FirefliesSentence, MeetingCreateInput } from './types';
export class MeetingFormatter {
// Format timestamp from seconds to MM:SS
private static formatTimestamp(timeStr: string): string {
const seconds = parseFloat(timeStr);
if (isNaN(seconds)) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Format full transcript from sentences
private static formatTranscript(sentences: FirefliesSentence[]): string {
if (!sentences || sentences.length === 0) return '';
let transcript = '';
let currentSpeaker = '';
for (const sentence of sentences) {
const timestamp = this.formatTimestamp(sentence.start_time);
const speaker = sentence.speaker_name || 'Unknown';
// Add speaker header when speaker changes
if (speaker !== currentSpeaker) {
currentSpeaker = speaker;
transcript += `\n**${speaker}** [${timestamp}]\n`;
}
transcript += `${sentence.text} `;
}
return transcript.trim();
}
static formatNoteBody(meetingData: FirefliesMeetingData): string {
const meetingDate = meetingData.date ? new Date(meetingData.date) : null;
const hasValidDate = meetingDate instanceof Date && !Number.isNaN(meetingDate.getTime());
const formattedDate = hasValidDate
? meetingDate.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: 'Unknown date';
const durationMinutes = Math.round(meetingData.duration);
let noteBody = `**Date:** ${formattedDate}\n`;
noteBody += `**Duration:** ${durationMinutes} minutes\n`;
if (meetingData.participants.length > 0) {
const participantNames = meetingData.participants.map(p => p.name).join(', ');
noteBody += `**Participants:** ${participantNames}\n`;
}
// Overview section
if (meetingData.summary?.overview) {
noteBody += `\n## Overview\n${meetingData.summary.overview}\n`;
}
// Detailed AI Notes (the rich content from Fireflies)
if (meetingData.summary?.notes) {
noteBody += `\n## Meeting Notes\n${meetingData.summary.notes}\n`;
}
// Bullet gist with emojis (if available and different from notes)
if (meetingData.summary?.bullet_gist && !meetingData.summary?.notes) {
noteBody += `\n## Key Points\n${meetingData.summary.bullet_gist}\n`;
}
// Meeting outline with timestamps (shorthand_bullet contains the timestamped outline)
const outline = meetingData.summary?.outline || meetingData.summary?.shorthand_bullet;
if (outline) {
noteBody += `\n## Outline\n${outline}\n`;
}
// Key topics
if (meetingData.summary?.topics_discussed?.length) {
noteBody += `\n## Key Topics\n`;
meetingData.summary.topics_discussed.forEach(topic => {
noteBody += `- ${topic}\n`;
});
}
// Action items
if (meetingData.summary?.action_items?.length) {
noteBody += `\n## Action Items\n`;
meetingData.summary.action_items.forEach(item => {
noteBody += `- [ ] ${item}\n`;
});
}
// Insights section
noteBody += `\n## Insights\n`;
if (meetingData.summary?.keywords?.length) {
noteBody += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
}
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
noteBody += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
}
if (meetingData.summary?.meeting_type) {
noteBody += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
}
// Speaker analytics (Business+)
if (meetingData.analytics?.speakers?.length) {
noteBody += `\n### Speaker Stats\n`;
for (const speaker of meetingData.analytics.speakers) {
const talkTime = Math.round(speaker.duration / 60);
noteBody += `- **${speaker.name}**: ${talkTime} min talk time, ${speaker.word_count} words, ${speaker.questions} questions\n`;
}
}
// Meeting metrics (Business+)
if (meetingData.analytics?.categories) {
const cats = meetingData.analytics.categories;
noteBody += `\n### Meeting Metrics\n`;
noteBody += `- Questions asked: ${cats.questions}\n`;
noteBody += `- Tasks identified: ${cats.tasks}\n`;
if (cats.metrics > 0) noteBody += `- Metrics mentioned: ${cats.metrics}\n`;
if (cats.date_times > 0) noteBody += `- Dates/times discussed: ${cats.date_times}\n`;
}
// Resources section
noteBody += `\n## Resources\n`;
noteBody += `[View Full Transcript on Fireflies](${meetingData.transcript_url})\n`;
if (meetingData.video_url) {
noteBody += `[Watch Video Recording](${meetingData.video_url})\n`;
}
if (meetingData.audio_url) {
noteBody += `[Listen to Audio](${meetingData.audio_url})\n`;
}
if (meetingData.meeting_link) {
noteBody += `[Original Meeting Link](${meetingData.meeting_link})\n`;
}
return noteBody;
}
static toMeetingCreateInput(
meetingData: FirefliesMeetingData,
noteId?: string
): MeetingCreateInput {
const durationMinutes = Math.round(meetingData.duration);
const hasSummary = Boolean(meetingData.summary?.overview || meetingData.summary?.action_items?.length);
const hasAnalytics = Boolean(meetingData.analytics?.sentiments);
// Build input object with only defined values (omit null fields)
const input: MeetingCreateInput = {
name: meetingData.title,
meetingDate: meetingData.date,
duration: durationMinutes,
actionItemsCount: meetingData.summary?.action_items?.length || 0,
firefliesMeetingId: meetingData.id,
};
// Add direct relationship to note if noteId is provided
if (noteId) {
input.noteId = noteId;
}
// Basic fields (All plans)
if (meetingData.organizer_email) {
input.organizerEmail = meetingData.organizer_email;
}
if (meetingData.transcript_url?.trim()) {
input.transcriptUrl = {
primaryLinkUrl: meetingData.transcript_url,
primaryLinkLabel: 'View Transcript'
};
}
if (meetingData.meeting_link?.trim()) {
input.meetingLink = {
primaryLinkUrl: meetingData.meeting_link,
primaryLinkLabel: 'Join Meeting'
};
}
// Pro+ fields (transcript, summary, notes, keywords, audio)
if (meetingData.sentences?.length) {
input.transcript = this.formatTranscript(meetingData.sentences);
}
if (meetingData.summary?.overview) {
input.overview = meetingData.summary.overview;
}
if (meetingData.summary?.notes) {
input.notes = meetingData.summary.notes;
}
if (meetingData.summary?.keywords?.length) {
input.keywords = meetingData.summary.keywords.join(', ');
}
if (meetingData.audio_url?.trim()) {
input.audioUrl = {
primaryLinkUrl: meetingData.audio_url,
primaryLinkLabel: 'Listen to Audio'
};
}
// Business+ fields (analytics, video, detailed summary)
if (meetingData.summary?.meeting_type) {
input.meetingType = meetingData.summary.meeting_type;
}
if (meetingData.summary?.topics_discussed?.length) {
input.topics = meetingData.summary.topics_discussed.join(', ');
}
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
input.positivePercent = sentiments.positive_pct;
input.negativePercent = sentiments.negative_pct;
input.neutralPercent = sentiments.neutral_pct;
}
if (meetingData.video_url?.trim()) {
input.videoUrl = {
primaryLinkUrl: meetingData.video_url,
primaryLinkLabel: 'Watch Video'
};
}
// Import status based on data completeness
const isPartial = !hasSummary && !hasAnalytics;
input.importStatus = isPartial ? 'PARTIAL' : 'SUCCESS';
input.lastImportAttempt = new Date().toISOString();
input.importAttempts = 1;
return input;
}
static toFailedMeetingCreateInput(
meetingId: string,
title: string,
error: string,
attempts: number = 1
): MeetingCreateInput {
const currentDate = new Date().toISOString();
return {
name: title || `Failed Meeting Import - ${meetingId}`,
meetingDate: currentDate,
duration: 0,
actionItemsCount: 0,
firefliesMeetingId: meetingId,
importStatus: 'FAILED',
importError: error,
lastImportAttempt: currentDate,
importAttempts: attempts,
};
}
}
@@ -0,0 +1,160 @@
import { type FirefliesApiClient } from './fireflies-api-client';
import { MeetingFormatter } from './formatters';
import { createLogger } from './logger';
import { type TwentyCrmService } from './twenty-crm-service';
import type {
FirefliesPlan,
FirefliesTranscriptListOptions,
SummaryFetchConfig,
} from './types';
const logger = createLogger('historical-importer');
export type HistoricalImportFilters = FirefliesTranscriptListOptions;
export type HistoricalImportOptions = {
dryRun?: boolean;
autoCreateContacts: boolean;
summaryConfig: SummaryFetchConfig;
plan: FirefliesPlan;
};
export type HistoricalImportResult = {
dryRun: boolean;
totalListed: number;
imported: number;
skippedExisting: number;
failed: Array<{ meetingId: string; reason: string }>;
summaryPending: number;
statuses: Array<{
meetingId: string;
title?: string;
status: 'imported' | 'skipped_existing' | 'failed' | 'dry_run' | 'pending_summary';
reason?: string;
}>;
};
export class HistoricalImporter {
private firefliesClient: FirefliesApiClient;
private twentyService: TwentyCrmService;
constructor(firefliesClient: FirefliesApiClient, twentyService: TwentyCrmService) {
this.firefliesClient = firefliesClient;
this.twentyService = twentyService;
}
async run(
filters: HistoricalImportFilters,
options: HistoricalImportOptions,
): Promise<HistoricalImportResult> {
const { dryRun = false, autoCreateContacts, summaryConfig, plan } = options;
logger.info('Listing Fireflies transcripts for historical import...');
const transcripts = await this.firefliesClient.listTranscripts(filters);
logger.info(`Found ${transcripts.length} transcript(s) to process`);
let imported = 0;
let skippedExisting = 0;
let summaryPending = 0;
const failed: Array<{ meetingId: string; reason: string }> = [];
const statuses: HistoricalImportResult['statuses'] = [];
for (const transcript of transcripts) {
const meetingId = transcript.id;
try {
const existing = await this.twentyService.findMeetingByFirefliesId(meetingId);
if (existing) {
logger.debug(`Skipping ${meetingId}: already exists in Twenty (${existing.id})`);
skippedExisting += 1;
statuses.push({
meetingId,
title: transcript.title,
status: 'skipped_existing',
});
continue;
}
logger.info(`Fetching meeting ${meetingId} details`);
const { data: meetingData, summaryReady } =
await this.firefliesClient.fetchMeetingDataWithRetry(
meetingId,
summaryConfig,
plan,
);
const isPendingSummary = summaryReady === false;
if (isPendingSummary) {
summaryPending += 1;
}
if (dryRun) {
logger.info(`[dry-run] Would import meeting "${meetingData.title}" (${meetingId})`);
imported += 1;
statuses.push({
meetingId,
title: meetingData.title,
status: isPendingSummary ? 'pending_summary' : 'dry_run',
reason: isPendingSummary ? 'summary not ready' : undefined,
});
continue;
}
const { matchedContacts, unmatchedParticipants } =
await this.twentyService.matchParticipantsToContacts(
meetingData.participants,
);
const newContactIds = autoCreateContacts
? await this.twentyService.createContactsForUnmatched(unmatchedParticipants)
: [];
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
const noteId = await this.twentyService.createNoteOnly(
`Meeting: ${meetingData.title}`,
noteBody,
);
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
const createdMeetingId = await this.twentyService.createMeeting(meetingInput);
for (const contactId of allContactIds) {
await this.twentyService.createNoteTarget(noteId, contactId);
}
logger.info(
`Imported meeting "${meetingData.title}" (${meetingId}) as ${createdMeetingId}`,
);
imported += 1;
statuses.push({
meetingId,
title: meetingData.title,
status: isPendingSummary ? 'pending_summary' : 'imported',
reason: isPendingSummary ? 'summary not ready' : undefined,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
logger.error(`Failed to import meeting ${meetingId}: ${reason}`);
failed.push({ meetingId, reason });
statuses.push({
meetingId,
title: transcript.title,
status: 'failed',
reason,
});
}
}
return {
dryRun,
totalListed: transcripts.length,
imported,
skippedExisting,
failed,
summaryPending,
statuses,
};
}
}
@@ -0,0 +1,31 @@
// Main exports for the Fireflies integration
export { config, main } from './receive-fireflies-notes';
// Types
export type {
FirefliesMeetingData,
FirefliesParticipant,
FirefliesWebhookPayload,
ProcessResult,
SummaryFetchConfig,
SummaryStrategy
} from './types';
// Services (for advanced usage)
export { FirefliesApiClient } from './fireflies-api-client';
export { MeetingFormatter } from './formatters';
export { TwentyCrmService } from './twenty-crm-service';
export { WebhookHandler } from './webhook-handler';
// Objects
export { Meeting } from './objects';
// Utilities
export { createLogger } from './logger';
export { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts, toBoolean } from './utils';
export {
getWebhookSecretFingerprint,
isValidFirefliesPayload,
verifyWebhookSignature
} from './webhook-validator';
@@ -0,0 +1,142 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
export type LoggerConfig = {
logLevel: LogLevel;
isTestEnvironment: boolean;
captureForResponse: boolean;
};
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
silent: 4,
};
const loggerRegistry = new Set<AppLogger>();
export class AppLogger {
private config: LoggerConfig;
private context: string;
private capturedLogs: 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,
captureForResponse: process.env.CAPTURE_LOGS === 'true',
};
// Silence logs in test environment unless explicitly overridden
if (this.config.isTestEnvironment && process.env.LOG_LEVEL === undefined) {
this.config.logLevel = 'silent';
}
}
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];
}
private safeStringify(value: unknown): string {
try {
if (typeof value === 'string') return value;
return JSON.stringify(value);
} catch {
return '[unserializable]';
}
}
private captureLog(level: LogLevel, message: string, ...args: unknown[]): void {
if (!this.config.captureForResponse) {
return;
}
const timestamp = new Date().toISOString();
const formattedMessage =
args.length > 0
? `${message} ${args.map((arg) => this.safeStringify(arg)).join(' ')}`
: message;
this.capturedLogs.push(
`[${timestamp}] [${level.toUpperCase()}] [${this.context}] ${formattedMessage}`,
);
}
debug(message: string, ...args: unknown[]): void {
this.captureLog('debug', message, ...args);
if (this.shouldLog('debug')) {
// oxlint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
info(message: string, ...args: unknown[]): void {
this.captureLog('info', message, ...args);
if (this.shouldLog('info')) {
// oxlint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
warn(message: string, ...args: unknown[]): void {
this.captureLog('warn', message, ...args);
if (this.shouldLog('warn')) {
// oxlint-disable-next-line no-console
console.warn(`[${this.context}] ${message}`, ...args);
}
}
error(message: string, ...args: unknown[]): void {
this.captureLog('error', message, ...args);
if (this.shouldLog('error')) {
// oxlint-disable-next-line no-console
console.error(`[${this.context}] ${message}`, ...args);
}
}
// For fatal errors, security issues, or data corruption - always visible
critical(message: string, ...args: unknown[]): void {
this.captureLog('error', `CRITICAL: ${message}`, ...args);
// oxlint-disable-next-line no-console
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
}
getCapturedLogs(): string[] {
return [...this.capturedLogs];
}
clearCapturedLogs(): void {
this.capturedLogs = [];
}
}
export const createLogger = (context: string): AppLogger => {
const logger = new AppLogger(context);
loggerRegistry.add(logger);
return logger;
};
export const removeLogger = (logger: AppLogger): void => {
loggerRegistry.delete(logger);
};
export const getAllCapturedLogs = (): string[] => {
const allLogs: string[] = [];
for (const logger of loggerRegistry) {
allLogs.push(...logger.getCapturedLogs());
}
return allLogs.sort();
};
export const clearAllCapturedLogs = (): void => {
for (const logger of loggerRegistry) {
logger.clearCapturedLogs();
}
};
@@ -0,0 +1,2 @@
export { Meeting } from './meeting';
@@ -0,0 +1,14 @@
import { Object } from 'twenty-sdk/define';
@Object({
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
nameSingular: 'meeting',
namePlural: 'meetings',
labelSingular: 'Meeting',
labelPlural: 'Meetings',
description:
'Meetings imported from Fireflies with AI-generated summaries, sentiment, and action items.',
icon: 'IconVideo',
})
export class Meeting {}
@@ -0,0 +1,29 @@
import { type FunctionConfig } from 'twenty-sdk/define';
import type { ProcessResult } from './types';
import { WebhookHandler } from './webhook-handler';
export const main = async (
params: unknown,
headers?: Record<string, string>
): Promise<ProcessResult> => {
const handler = new WebhookHandler();
return handler.handle(params, headers);
};
export const config: FunctionConfig = {
universalIdentifier: '2d3ea303-667c-4bbe-9e3d-db6ffb9d6c74',
name: 'receive-fireflies-notes',
description:
'Receives Fireflies webhooks, fetches meeting summaries, and stores them in Twenty.',
timeoutSeconds: 30,
triggers: [
{
universalIdentifier: 'a2117dc1-7674-4c7e-9d70-9feb9820e9e8',
type: 'route',
path: '/webhook/fireflies',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};
@@ -0,0 +1,577 @@
import { createLogger } from './logger';
import type {
Contact,
CreateMeetingResponse,
CreateNoteResponse,
CreatePersonResponse,
FindMeetingResponse,
FindPeopleResponse,
FirefliesParticipant,
GraphQLResponse,
IdNode,
MeetingCreateInput,
} from './types';
const logger = createLogger('20 CRM Service');
export class TwentyCrmService {
private apiKey: string;
private apiUrl: string;
private isTestEnvironment: boolean;
constructor(apiKey: string, apiUrl: string) {
if (!apiKey) {
logger.critical('TWENTY_API_KEY is required but not provided - this is a critical configuration error');
throw new Error('TWENTY_API_KEY is required');
}
this.apiKey = apiKey;
this.apiUrl = apiUrl;
this.isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
}
async findExistingMeeting(title: string): Promise<IdNode | undefined> {
const query = `
query FindMeeting($title: String!) {
meetings(filter: { name: { eq: $title } }) {
edges { node { id } }
}
}
`;
const variables = { title };
const response = await this.gqlRequest<FindMeetingResponse>(query, variables);
return response.data?.meetings?.edges?.[0]?.node;
}
async findMeetingByFirefliesId(meetingId: string): Promise<IdNode | undefined> {
const query = `
query FindMeetingByFirefliesId($meetingId: String!) {
meetings(filter: { firefliesMeetingId: { eq: $meetingId } }) {
edges { node { id } }
}
}
`;
const variables = { meetingId };
const response = await this.gqlRequest<FindMeetingResponse>(query, variables);
return response.data?.meetings?.edges?.[0]?.node;
}
async matchParticipantsToContacts(
participants: FirefliesParticipant[],
): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
if (participants.length === 0) {
return { matchedContacts: [], unmatchedParticipants: [] };
}
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
let matchedContacts: Contact[] = [];
let unmatchedParticipants: FirefliesParticipant[] = [];
if (participantsWithEmails.length > 0) {
const emailMatches = await this.matchByEmail(participantsWithEmails);
matchedContacts.push(...emailMatches.matchedContacts);
unmatchedParticipants.push(...emailMatches.unmatchedParticipants);
}
if (participantsNameOnly.length > 0) {
const nameMatches = await this.matchByName(participantsNameOnly, matchedContacts);
matchedContacts.push(...nameMatches.matchedContacts);
unmatchedParticipants.push(...nameMatches.unmatchedParticipants);
}
return { matchedContacts, unmatchedParticipants };
}
private async matchByEmail(participants: FirefliesParticipant[]): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
const emails = participants.map(({ email }) => email).filter(Boolean);
const query = `
query FindPeople($emails: [String!]!) {
people(filter: { emails: { primaryEmail: { in: $emails } } }) {
edges { node { id emails { primaryEmail } } }
}
}
`;
const variables = { emails };
const response = await this.gqlRequest<FindPeopleResponse>(query, variables);
const people = response.data?.people;
if (!people?.edges) {
return { matchedContacts: [], unmatchedParticipants: participants };
}
const matchedContacts = people.edges.map(({ node }) => ({
id: node.id,
email: node.emails?.primaryEmail || ''
}));
const matchedEmails = new Set(
matchedContacts
.map(({ email }) => email)
.filter((email) => Boolean(email)),
);
const unmatchedParticipants = participants.filter(
({ email }) => !matchedEmails.has(email)
);
return { matchedContacts, unmatchedParticipants };
}
private async matchByName(
participants: FirefliesParticipant[],
alreadyMatchedContacts: Contact[]
): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
const matchedContacts: Contact[] = [];
const unmatchedParticipants: FirefliesParticipant[] = [];
const alreadyMatchedIds = new Set(alreadyMatchedContacts.map(c => c.id));
for (const participant of participants) {
const nameMatch = await this.findContactByName(participant.name);
if (nameMatch && !alreadyMatchedIds.has(nameMatch.id)) {
matchedContacts.push(nameMatch);
alreadyMatchedIds.add(nameMatch.id);
} else {
unmatchedParticipants.push(participant);
}
}
return { matchedContacts, unmatchedParticipants };
}
private async findContactByName(name: string): Promise<Contact | null> {
if (!name || !name.trim()) {
return null;
}
const nameParts = name.trim().split(/\s+/);
const firstName = nameParts[0];
const lastName = nameParts.slice(1).join(' ');
const hasLastName = Boolean(lastName);
const query = hasLastName
? `
query FindPeopleByName($firstName: String!, $lastName: String!) {
people(filter: {
and: [
{ name: { firstName: { eq: $firstName } } }
{ name: { lastName: { eq: $lastName } } }
]
}) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`
: `
query FindPeopleByName($firstName: String!) {
people(filter: {
name: { firstName: { eq: $firstName } }
}) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`;
const variables: Record<string, unknown> = hasLastName
? { firstName, lastName }
: { firstName };
try {
const response = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(query, variables);
const people = response.data?.people?.edges;
if (people && people.length > 0) {
const person = people[0].node;
return {
id: person.id,
email: person.emails?.primaryEmail || ''
};
}
if (hasLastName) {
const fuzzyQuery = `
query FindPeopleByNameFuzzy($firstName: String!) {
people(filter: { name: { firstName: { ilike: $firstName } } }) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`;
const fuzzyResponse = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(fuzzyQuery, { firstName: `%${firstName}%` });
const fuzzyPeople = fuzzyResponse.data?.people?.edges;
if (fuzzyPeople && fuzzyPeople.length > 0) {
const bestMatch = fuzzyPeople.find((edge) => {
const personLastName = edge.node.name?.lastName || '';
return personLastName.toLowerCase().includes(lastName.toLowerCase());
});
if (bestMatch) {
const person = bestMatch.node;
return {
id: person.id,
email: person.emails?.primaryEmail || ''
};
}
}
}
return null;
} catch {
return null;
}
}
async createContactsForUnmatched(
participants: FirefliesParticipant[],
): Promise<string[]> {
const newContactIds: string[] = [];
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
if (participantsWithEmails.length > 0) {
const emailContactIds = await this.createContactsWithEmails(participantsWithEmails);
newContactIds.push(...emailContactIds);
}
if (participantsNameOnly.length > 0) {
const nameContactIds = await this.createContactsNameOnly(participantsNameOnly);
newContactIds.push(...nameContactIds);
}
return newContactIds;
}
private async createContactsWithEmails(participants: FirefliesParticipant[]): Promise<string[]> {
const newContactIds: string[] = [];
const uniqueParticipants = participants.reduce<FirefliesParticipant[]>((unique, participant) => {
const existing = unique.find(p => p.email === participant.email);
if (!existing) {
unique.push(participant);
} else {
logger.warn(`Duplicate participant email detected: ${participant.email}. Using first occurrence.`);
}
return unique;
}, []);
for (const participant of uniqueParticipants) {
const [firstName, ...lastNameParts] = participant.name.trim().split(/\s+/);
const lastName = lastNameParts.join(' ');
const mutation = `
mutation CreatePerson($data: PersonCreateInput!) {
createPerson(data: $data) { id }
}
`;
const variables = {
data: {
name: { firstName, lastName },
emails: { primaryEmail: participant.email },
},
};
try {
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables, {
suppressErrorCodes: ['BAD_USER_INPUT'],
suppressErrorMessageIncludes: ['Duplicate Emails', 'duplicate entry'],
});
if (!response.data?.createPerson?.id) {
throw new Error(`Failed to create contact for ${participant.email}`);
}
newContactIds.push(response.data.createPerson.id);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage.includes('Duplicate Emails') || errorMessage.includes('BAD_USER_INPUT')) {
logger.warn(`Skipping contact creation for ${participant.email} due to duplicate email constraint: ${errorMessage}`);
continue;
}
throw error;
}
}
return newContactIds;
}
private async createContactsNameOnly(participants: FirefliesParticipant[]): Promise<string[]> {
const newContactIds: string[] = [];
const uniqueParticipants = participants.reduce<FirefliesParticipant[]>((unique, participant) => {
const existing = unique.find(p =>
p.name.toLowerCase().trim() === participant.name.toLowerCase().trim()
);
if (!existing) {
unique.push(participant);
} else {
logger.warn(`Duplicate participant name detected: ${participant.name}. Using first occurrence.`);
}
return unique;
}, []);
for (const participant of uniqueParticipants) {
const existingContact = await this.findContactByName(participant.name);
if (existingContact) {
logger.warn(`Contact with name "${participant.name}" already exists. Skipping creation.`);
continue;
}
const [firstName, ...lastNameParts] = participant.name.trim().split(/\s+/);
const lastName = lastNameParts.join(' ');
const mutation = `
mutation CreatePerson($data: PersonCreateInput!) {
createPerson(data: $data) { id }
}
`;
const variables = {
data: {
name: { firstName, lastName },
},
};
try {
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables);
if (!response.data?.createPerson?.id) {
throw new Error(`Failed to create contact for ${participant.name}`);
}
newContactIds.push(response.data.createPerson.id);
logger.debug(`Created contact for name-only participant: ${participant.name}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.warn(`Failed to create contact for ${participant.name}: ${errorMessage}`);
continue;
}
}
return newContactIds;
}
async createNote(
contactId: string,
title: string,
body: string
): Promise<string> {
const noteId = await this.createNoteOnly(title, body);
await this.createNoteTarget(noteId, contactId);
return noteId;
}
async createNoteOnly(
title: string,
body: string
): Promise<string> {
const mutation = `
mutation CreateNote($data: NoteCreateInput!) {
createNote(data: $data) { id }
}
`;
const variables = {
data: {
title,
bodyV2: {
markdown: body.trim()
},
},
};
const response = await this.gqlRequest<CreateNoteResponse>(mutation, variables);
if (!response.data?.createNote?.id) {
throw new Error(`Failed to create note`);
}
return response.data.createNote.id;
}
async createNoteTarget(noteId: string, contactId: string): Promise<void> {
const mutation = `
mutation CreateNoteTarget($data: NoteTargetCreateInput!) {
createNoteTarget(data: $data) {
id
noteId
personId
}
}
`;
const variables = {
data: {
noteId,
personId: contactId,
},
};
await this.gqlRequest<{ createNoteTarget: { id: string; noteId: string; personId: string } }>(mutation, variables);
}
async createMeeting(meetingData: MeetingCreateInput): Promise<string> {
const mutation = `
mutation CreateMeeting($data: MeetingCreateInput!) {
createMeeting(data: $data) { id }
}
`;
const variables = { data: meetingData };
if (!this.isTestEnvironment) {
logger.debug('createMeeting variables:', JSON.stringify(variables, null, 2));
}
const response = await this.gqlRequest<CreateMeetingResponse>(mutation, variables);
if (!response.data?.createMeeting?.id) {
throw new Error('Failed to create meeting: Invalid response from server');
}
return response.data.createMeeting.id;
}
private async gqlRequest<T>(
query: string,
variables?: Record<string, unknown>,
options?: { suppressErrorCodes?: string[]; suppressErrorMessageIncludes?: string[] }
): Promise<GraphQLResponse<T>> {
const url = `${this.apiUrl}/graphql`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
let errorMessage = `GraphQL request failed with status ${res.status}`;
try {
const errorText = await res.text();
if (errorText) {
errorMessage += `: ${errorText}`;
}
} catch {
// Ignore error when reading response body
}
throw new Error(errorMessage);
}
const json = await res.json() as GraphQLResponse<T> & {
errors?: Array<{ message?: string; extensions?: Record<string, unknown> }>
};
if (json?.errors && Array.isArray(json.errors) && json.errors.length > 0) {
const firstError = json.errors[0];
const errorMessage = firstError?.message || 'GraphQL error';
const errorCode = firstError?.extensions?.code as string | undefined;
if (errorCode) {
throw new Error(`${errorMessage} (Code: ${errorCode})`);
}
throw new Error(errorMessage);
}
return json;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const suppressByCode = options?.suppressErrorCodes?.some((code) =>
message.includes(code),
);
const suppressByMessage = options?.suppressErrorMessageIncludes?.some((substring) =>
message.includes(substring),
);
if (!suppressByCode && !suppressByMessage) {
logger.error('GraphQL request error:', error);
}
throw error;
}
}
async createFailedMeeting(meetingData: MeetingCreateInput): Promise<string> {
const mutation = `
mutation CreateMeeting($data: MeetingCreateInput!) {
createMeeting(data: $data) { id }
}
`;
const variables = { data: meetingData };
if (!this.isTestEnvironment) {
logger.debug('createFailedMeeting variables:', JSON.stringify(variables, null, 2));
}
const response = await this.gqlRequest<CreateMeetingResponse>(mutation, variables);
if (!response.data?.createMeeting?.id) {
throw new Error('Failed to create failed meeting record: Invalid response from server');
}
return response.data.createMeeting.id;
}
async findFailedMeetings(): Promise<Array<{
id: string;
name: string;
firefliesMeetingId: string;
importError: string;
lastImportAttempt: string;
importAttempts: number;
createdAt: string;
}>> {
const query = `
query FindFailedMeetings {
meetings(filter: { importStatus: { eq: "FAILED" } }) {
edges {
node {
id
name
firefliesMeetingId
importError
lastImportAttempt
importAttempts
createdAt
}
}
}
}
`;
const response = await this.gqlRequest<{ meetings: { edges: Array<{ node: { id: string; name: string; firefliesMeetingId: string; importError: string; lastImportAttempt: string; importAttempts: number; createdAt: string } }> } }>(query);
return response.data?.meetings?.edges?.map((edge) => edge.node) || [];
}
async retryFailedMeeting(meetingId: string, updatedData: Partial<MeetingCreateInput>): Promise<void> {
const mutation = `
mutation UpdateMeeting($where: MeetingWhereUniqueInput!, $data: MeetingUpdateInput!) {
updateMeeting(where: $where, data: $data) { id }
}
`;
const variables = {
where: { id: meetingId },
data: {
...updatedData,
lastImportAttempt: new Date().toISOString(),
importAttempts: { increment: 1 }
}
};
await this.gqlRequest<{ updateMeeting: { id: string } }>(mutation, variables);
}
}
@@ -0,0 +1,235 @@
// Fireflies API Types
export type FirefliesParticipant = {
email: string;
name: string;
};
export type FirefliesWebhookPayload = {
meetingId: string;
eventType: string;
clientReferenceId?: string;
};
// Transcript sentence from Fireflies API
export type FirefliesSentence = {
index: number;
speaker_name: string;
text: string;
start_time: string;
end_time: string;
ai_filters?: {
task?: boolean;
question?: boolean;
sentiment?: string;
};
};
// Speaker analytics from Fireflies API (Business+)
export type FirefliesSpeakerAnalytics = {
speaker_id: string;
name: string;
duration: number;
word_count: number;
longest_monologue: number;
filler_words: number;
questions: number;
words_per_minute: number;
};
// Based on Fireflies GraphQL API transcript schema
// See: https://docs.fireflies.ai/graphql-api/query/transcript
export type FirefliesMeetingData = {
id: string;
title: string;
date: string;
duration: number;
participants: FirefliesParticipant[];
organizer_email?: string;
// Full transcript (Pro+)
sentences?: FirefliesSentence[];
summary: {
// Pro+ fields
action_items: string[];
keywords?: string[];
overview: string;
notes?: string; // Detailed AI-generated meeting notes
gist?: string; // 1-sentence summary
bullet_gist?: string; // Bullet point summary with emojis
short_summary?: string; // Single paragraph summary
short_overview?: string; // Brief overview
outline?: string; // Meeting outline with timestamps
shorthand_bullet?: string;
// Business+ fields
topics_discussed?: string[];
meeting_type?: string;
transcript_chapters?: string[];
};
// Business+ analytics
analytics?: {
sentiments?: {
positive_pct: number;
negative_pct: number;
neutral_pct: number;
};
categories?: {
questions: number;
tasks: number;
metrics: number;
date_times: number;
};
speakers?: FirefliesSpeakerAnalytics[];
};
meeting_info?: {
summary_status?: string;
};
// URLs
transcript_url: string;
audio_url?: string; // Pro+
video_url?: string; // Business+
meeting_link?: string; // All plans
summary_status?: string;
};
export type FirefliesTranscriptListItem = {
id: string;
title: string;
date?: string;
duration?: number;
organizer_email?: string;
participants?: string[];
transcript_url?: string;
meeting_link?: string;
summary_status?: string;
};
export type FirefliesTranscriptListOptions = {
limit?: number;
skip?: number;
organizers?: string[];
participants?: string[];
hostEmail?: string;
participantEmail?: string;
userId?: string;
channelId?: string;
mine?: boolean;
fromDate?: number;
toDate?: number;
pageSize?: number;
maxRecords?: number;
};
// Configuration Types
export type SummaryStrategy = 'immediate_only' | 'immediate_with_retry' | 'delayed_polling' | 'basic_only';
export type SummaryFetchConfig = {
strategy: SummaryStrategy;
retryAttempts: number;
retryDelay: number;
pollInterval: number;
maxPolls: number;
};
export const FIREFLIES_PLANS = {
FREE: 'free',
PRO: 'pro',
BUSINESS: 'business',
ENTERPRISE: 'enterprise',
} as const;
export type FirefliesPlan = typeof FIREFLIES_PLANS[keyof typeof FIREFLIES_PLANS];
export type WebhookConfig = {
secret: string;
apiUrl: string;
};
// Processing Result Types
export type ProcessResult = {
success: boolean;
meetingId?: string;
noteIds?: string[];
newContacts?: string[];
errors?: string[];
debug?: string[];
summaryReady?: boolean;
summaryPending?: boolean;
enhancementScheduled?: boolean;
actionItemsCount?: number;
sentimentAnalysis?: {
positive_pct: number;
negative_pct: number;
neutral_pct: number;
};
meetingType?: string;
keyTopics?: string[];
};
// Twenty CRM Types
export type GraphQLResponse<T> = {
data: T;
errors?: Array<{
message?: string;
extensions?: { code?: string }
}>;
};
export type IdNode = { id: string };
export type FindMeetingResponse = {
meetings: { edges: Array<{ node: IdNode }> };
};
export type FindPeopleResponse = {
people: { edges: Array<{ node: { id: string; emails: { primaryEmail: string } } }> };
};
export type CreatePersonResponse = {
createPerson: { id: string }
};
export type CreateNoteResponse = {
createNote: { id: string }
};
export type CreateMeetingResponse = {
createMeeting: { id: string }
};
export type Contact = {
id: string;
email: string;
};
// Twenty CRM Meeting custom field input
// Maps to fields defined in add-meeting-fields.ts
export type MeetingCreateInput = {
name: string;
noteId?: string | null;
// Basic fields (All plans)
meetingDate: string;
duration: number;
firefliesMeetingId: string;
organizerEmail?: string | null;
transcriptUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
meetingLink?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
// Pro+ fields
transcript?: string | null;
overview?: string | null;
notes?: string | null;
keywords?: string | null;
audioUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
// Business+ fields
meetingType?: string | null;
topics?: string | null;
actionItemsCount: number;
positivePercent?: number | null;
negativePercent?: number | null;
neutralPercent?: number | null;
videoUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
// Import tracking
importStatus?: 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'PENDING' | 'RETRYING' | null;
importError?: string | null;
lastImportAttempt?: string | null;
importAttempts?: number | null;
};
@@ -0,0 +1,41 @@
import { FIREFLIES_PLANS, type FirefliesPlan, type SummaryFetchConfig, type SummaryStrategy } from './types';
export const toBoolean = (value: string | undefined, defaultValue: boolean): boolean => {
if (value === undefined) return defaultValue;
const normalized = value.trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes';
};
export const getApiUrl = (): string => {
return process.env.SERVER_URL || 'http://localhost:3000';
};
export const getSummaryFetchConfig = (): SummaryFetchConfig => {
const strategy = (process.env.FIREFLIES_SUMMARY_STRATEGY as SummaryStrategy) || 'immediate_with_retry';
// Ultra-conservative defaults to respect 50 requests/day API limit
// With 3 attempts at 15-minute intervals, max 3 API calls per webhook (45 minutes total)
return {
strategy,
retryAttempts: parseInt(process.env.FIREFLIES_RETRY_ATTEMPTS || '3', 10),
retryDelay: parseInt(process.env.FIREFLIES_RETRY_DELAY || '120000', 10), // 2 minutes
pollInterval: parseInt(process.env.FIREFLIES_POLL_INTERVAL || '120000', 10), // 2 minutes
maxPolls: parseInt(process.env.FIREFLIES_MAX_POLLS || '3', 10),
};
};
export const shouldAutoCreateContacts = (): boolean => {
return toBoolean(process.env.AUTO_CREATE_CONTACTS, true);
};
export const getFirefliesPlan = (): FirefliesPlan => {
const plan = (process.env.FIREFLIES_PLAN || FIREFLIES_PLANS.FREE).toLowerCase();
if (plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE) {
return plan as FirefliesPlan;
}
if (plan === FIREFLIES_PLANS.PRO) {
return FIREFLIES_PLANS.PRO;
}
return FIREFLIES_PLANS.FREE;
};
@@ -0,0 +1,382 @@
import { FirefliesApiClient } from './fireflies-api-client';
import { MeetingFormatter } from './formatters';
import { clearAllCapturedLogs, createLogger, getAllCapturedLogs } from './logger';
import { TwentyCrmService } from './twenty-crm-service';
import type { FirefliesWebhookPayload, ProcessResult } from './types';
import { getApiUrl, getFirefliesPlan, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
import {
getWebhookSecretFingerprint,
isValidFirefliesPayload,
verifyWebhookSignature
} from './webhook-validator';
declare const process: { env: Record<string, string | undefined> };
export class WebhookHandler {
private debug: string[] = [];
private logger: ReturnType<typeof createLogger>;
constructor() {
this.logger = createLogger('fireflies');
}
private addDebugLogs(result: ProcessResult): ProcessResult {
if (process.env.CAPTURE_LOGS === 'true') {
const captured = getAllCapturedLogs();
result.debug = [...this.debug, ...captured];
} else {
delete result.debug;
}
return result;
}
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
clearAllCapturedLogs();
this.debug = [];
const result: ProcessResult = {
success: false,
noteIds: [],
newContacts: [],
errors: [],
};
try {
this.logger.debug('invoked');
this.logger.debug(`apiUrl=${getApiUrl()}`);
const paramKeys =
params && typeof params === 'object'
? Object.keys(params as Record<string, unknown>)
: [];
this.debug.push(
`paramsType=${typeof params}`,
`paramKeys=${paramKeys.join(',') || 'none'}`
);
// 0) Validate environment configuration
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!firefliesApiKey) {
this.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) {
this.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;
this.logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
// 2) Verify webhook signature
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
this.logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
this.verifySignature(payload, finalHeaders, webhookSecret);
this.logger.debug('signature verification: ok');
// 3) Fetch meeting data from Fireflies
const summaryConfig = getSummaryFetchConfig();
const firefliesPlan = getFirefliesPlan();
this.logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
this.logger.debug(`fetching meeting data from Fireflies API`);
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
payload.meetingId,
summaryConfig,
firefliesPlan
);
this.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) {
result.sentimentAnalysis = meetingData.analytics.sentiments;
}
}
// 4) Check for duplicate meetings
const twentyService = new TwentyCrmService(
twentyApiKey,
getApiUrl()
);
const existingMeetingById = await twentyService.findMeetingByFirefliesId(meetingData.id);
if (existingMeetingById) {
this.logger.debug(`meeting already exists by firefliesMeetingId id=${existingMeetingById.id}`);
result.success = true;
result.meetingId = existingMeetingById.id;
return this.addDebugLogs(result);
}
const existingMeetingByTitle = await twentyService.findExistingMeeting(meetingData.title);
if (existingMeetingByTitle) {
this.logger.debug(`meeting already exists by title id=${existingMeetingByTitle.id}`);
result.success = true;
result.meetingId = existingMeetingByTitle.id;
return this.addDebugLogs(result);
}
this.logger.debug('no existing meeting found, proceeding');
// 5) Match participants to existing contacts
this.logger.debug(`total participants from API: ${meetingData.participants.length}`);
meetingData.participants.forEach((p, idx) => {
this.logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
meetingData.participants
);
this.logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
unmatchedParticipants.forEach((p, idx) => {
this.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;
this.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];
this.logger.debug(`created note id=${noteId}`);
// 8) Create meeting with direct relationship to the note
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
this.logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
result.meetingId = await twentyService.createMeeting(meetingInput);
this.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
);
this.logger.debug(`linked note to ${allContactIds.length} participants`);
result.success = true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`error: ${message}`);
result.errors?.push(message);
// Try to create a failed meeting record for tracking
await this.createFailedMeetingRecord(params, message);
}
return this.addDebugLogs(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') {
this.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>);
this.logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
}
} catch (parseError) {
this.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);
this.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)) {
this.logger.debug(`detected payload under wrapper key "${key}"`);
payload = candidate as FirefliesWebhookPayload;
break;
}
}
}
if (!payload) {
this.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) {
this.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) {
this.logger.debug(`header keys: ${headerKeys.join(',')}`);
}
this.debug.push(`headerKeys=${headerKeys.join(',') || 'none'}`);
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) {
this.logger.debug('found signature inside payload');
}
const signature =
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
const payloadForSignature =
payloadSignature && 'x-hub-signature' in payloadRecord
? Object.fromEntries(
Object.entries(payloadRecord).filter(
([key]) => key.toLowerCase() !== 'x-hub-signature',
),
)
: payloadRecord;
const body =
typeof normalizedHeaders['body'] === 'string'
? normalizedHeaders['body']
: JSON.stringify(payloadForSignature);
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
if (!signatureCheck.isValid) {
this.debug.push(
`signatureProvided=${Boolean(signature)}`,
`signatureMatched=${signatureCheck.isValid}`,
`webhookSecretFingerprint=${getWebhookSecretFingerprint(webhookSecret)}`
);
this.logger.debug(
`signature check failed. headerPresent=${Boolean(
headerSignature,
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
);
this.logger.debug(`provided signature present=${Boolean(signature)}`);
this.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> {
for (const contactId of contactIds) {
try {
await twentyService.createNoteTarget(noteId, contactId);
this.logger.debug(`linked note ${noteId} to person ${contactId}`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`failed to link note to person ${contactId}: ${message}`);
}
}
}
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
try {
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!twentyApiKey) {
this.logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
return;
}
let meetingId = 'unknown';
let meetingTitle = 'Unknown Meeting';
const { payload } = this.parsePayload(params);
if (payload?.meetingId) {
meetingId = payload.meetingId;
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) {
this.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);
this.logger.debug(`Created failed meeting record: ${failedMeetingId}`);
} catch (recordError) {
this.logger.error(
`Failed to create failed meeting record: ${
recordError instanceof Error ? recordError.message : 'Unknown error'
}`,
);
}
}
}
@@ -0,0 +1,54 @@
import { createHash, createHmac } from 'crypto';
import type { FirefliesWebhookPayload } from './types';
export type SignatureVerificationResult = {
isValid: boolean;
computedSignature?: string;
};
export const verifyWebhookSignature = (
body: string,
signature: string | undefined,
secret: string
): SignatureVerificationResult => {
if (!signature) {
return { isValid: false };
}
try {
const hmac = createHmac('sha256', secret);
hmac.update(body, 'utf8');
const computed = hmac.digest('hex');
const computedSignature = `sha256=${computed}`;
const isValid = signature === computedSignature;
return { isValid, computedSignature };
} catch {
return { isValid: false };
}
};
export const getWebhookSecretFingerprint = (secret: string): string => {
return createHash('sha256').update(secret).digest('hex').substring(0, 8);
};
export const isValidFirefliesPayload = (
params: unknown
): params is FirefliesWebhookPayload => {
if (!params || typeof params !== 'object') {
return false;
}
const payload = params as Record<string, unknown>;
return (
typeof payload['meetingId'] === 'string' &&
payload['meetingId'].length > 0 &&
typeof payload['eventType'] === 'string' &&
payload['eventType'].length > 0 &&
(payload['clientReferenceId'] === undefined ||
typeof payload['clientReferenceId'] === 'string')
);
};
@@ -0,0 +1,26 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
File diff suppressed because it is too large Load Diff

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