Revert "[hacktoberfest] feat: add fireflies" (#15589)

Reverts twentyhq/twenty#15527 due to tsconfig base update
This commit is contained in:
Weiko 2025-11-04 12:25:23 +01:00 committed by GitHub
parent ad80a50354
commit 281070423f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 0 additions and 9023 deletions

View file

@ -1,105 +0,0 @@
# 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_LEVEL=pro
# 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
# =============================================================================
# Enable debug logging (true/false)
# When enabled, detailed logs will be output to console
# Useful for troubleshooting webhook processing
DEBUG_LOGS=false
# =============================================================================
# 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
#

File diff suppressed because one or more lines are too long

View file

@ -1,3 +0,0 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules

View file

@ -1,80 +0,0 @@
# Changelog
## [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

View file

@ -1,277 +0,0 @@
# Fireflies
Automatically captures meeting notes with AI-generated summaries and insights from Fireflies.ai into your Twenty CRM.
## 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 significantly by subscription tier:
| Feature | Free | Pro | Business | Enterprise |
|---------|------|-----|----------|------------|
| **API Rate Limit** | 50 requests/day | 50 requests/day | 60 requests/minute | 60 requests/minute |
| **Storage** | 800 mins/seat | 8,000 mins/seat | Unlimited | Unlimited |
| **AI Summaries** | Limited (20 credits) | Unlimited | Unlimited | Unlimited |
| **Video Upload** | 100MB max | 1.5GB max | 1.5GB max | 1.5GB max |
| **Advanced Features** | Basic transcription | AI apps, analytics | Team analytics, CI | Full API, SSO, compliance |
**Key Design Pattern:** Subscription-based API access uses **tiered rate limiting** rather than feature gating. Lower tiers get severely restricted throughput (50/day vs 60/minute = 1,700x difference), making production integrations effectively require Business+ plans.
**Pro Plan Limitation:** Despite "unlimited" AI summaries, the 50 requests/day limit severely constrains production usage for meeting-heavy organizations.
## 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 |
Then re-sync:
```bash
npx twenty-cli app sync
```
**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
## 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 tsc --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
### Past Meetings Retrieval
- **New trigger to retrieve past meetings from a contact** - Enable users to fetch historical meeting data from Fireflies for specific contacts, allowing retrospective capture and analysis of past interactions.
Next iteration 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.*

View file

@ -1,68 +0,0 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
displayName: 'Fireflies',
description: 'Sync Fireflies meeting summaries, sentiment, and action items into Twenty.',
icon: 'IconMicrophone',
applicationVariables: {
FIREFLIES_WEBHOOK_SECRET: {
universalIdentifier: 'f51f7646-be9f-4ba9-9b75-160dd288cd0c',
description: 'Secret key for verifying Fireflies webhook signatures',
isSecret: true,
},
FIREFLIES_API_KEY: {
universalIdentifier: 'faa41f07-b28e-4500-b1c0-ce4b3d27924c',
description: 'Fireflies GraphQL API key used to fetch meeting summaries',
isSecret: true,
},
TWENTY_API_KEY: {
universalIdentifier: '02756551-5bf7-4fb2-8e08-1f622008d305',
description: 'Twenty API key used when running scripts locally',
isSecret: true,
},
SERVER_URL: {
universalIdentifier: '9b3a5e8e-5973-4e6b-a059-2966075652aa',
description: 'Base URL for the Twenty workspace (default: http://localhost:3000)',
value: 'http://localhost:3000',
},
AUTO_CREATE_CONTACTS: {
universalIdentifier: 'c4fa946e-e06b-4d54-afb6-288b0ac75bdf',
description: 'Whether to auto-create contacts for unknown participants',
value: 'true',
},
DEBUG_LOGS: {
universalIdentifier: '009510df-5125-4683-941b-cce94b113242',
description: 'Enable verbose logging for debugging (true/false)',
value: 'false',
},
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;

View file

@ -1,23 +0,0 @@
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;

View file

@ -1,12 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/object.schema.json",
"universalIdentifier": "c8f4d3e1-2a7b-4e9f-8c1d-5a6b7e8f9c2a",
"standardId": "c8f4d3e1-2a7b-4e9f-8c1d-5a6b7e8f9c2a",
"nameSingular": "meeting",
"namePlural": "meetings",
"labelSingular": "Meeting",
"labelPlural": "Meetings",
"description": "Meetings imported from Fireflies with AI-generated summaries, sentiment, and action items.",
"icon": "IconVideo"
}

View file

@ -1,101 +0,0 @@
{
"name": "fireflies",
"version": "0.2.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"universalIdentifier": "af5128d7-192e-4bd3-bcf2-fec1ad767440",
"description": "Get fireflies meeting notes in Twenty",
"env": {
"FIREFLIES_API_KEY": {
"description": "Fireflies API key for authentication",
"isSecret": true
},
"FIREFLIES_WEBHOOK_SECRET": {
"description": "Secret key for validating Fireflies webhooks",
"isSecret": true
},
"FIREFLIES_PLAN_LEVEL": {
"description": "Fireflies plan level: free, pro, enterprise",
"value": "pro",
"isSecret": false
},
"TWENTY_API_KEY": {
"description": "Twenty CRM API key for authentication",
"isSecret": true
},
"SERVER_URL": {
"description": "Twenty CRM server URL",
"value": "http://localhost:3000",
"isSecret": false
},
"AUTO_CREATE_CONTACTS": {
"description": "Automatically create contacts for unknown participants (true/false)",
"value": "true",
"isSecret": false
},
"FIREFLIES_SUMMARY_STRATEGY": {
"description": "Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only",
"value": "immediate_with_retry",
"isSecret": false
},
"FIREFLIES_RETRY_ATTEMPTS": {
"description": "Number of retry attempts when fetching summaries",
"value": "30",
"isSecret": false
},
"FIREFLIES_RETRY_DELAY": {
"description": "Delay in milliseconds between retry attempts",
"value": "30000",
"isSecret": false
},
"FIREFLIES_POLL_INTERVAL": {
"description": "Polling interval (ms) when using delayed polling strategy",
"value": "60000",
"isSecret": false
},
"FIREFLIES_MAX_POLLS": {
"description": "Maximum number of polling attempts when waiting for summaries",
"value": "15",
"isSecret": false
},
"FIREFLIES_MAX_RETRY_ATTEMPTS": {
"description": "Maximum number of retry attempts when waiting for summaries",
"value": "30",
"isSecret": false
},
"FIREFLIES_MAX_POLL_INTERVAL": {
"description": "Maximum polling interval (ms) when waiting for summaries",
"value": "600000",
"isSecret": false
},
"DEBUG_LOGS": {
"description": "Enable debug logging (true/false)",
"value": "false",
"isSecret": false
}
},
"scripts": {
"test": "jest",
"setup:fields": "tsx scripts/add-meeting-fields.ts",
"test:webhook": "tsx scripts/test-webhook.ts"
},
"dependencies": {
"axios": "^1.13.1",
"dotenv": "^16.3.1",
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/jest": "^29.5.5",
"@types/node": "^24.9.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"tsx": "^4.19.3",
"typescript": "^5.9.3"
}
}

View file

@ -1,49 +0,0 @@
{
"name": "fireflies",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-apps/fireflies/src",
"projectType": "application",
"tags": [
"scope:apps"
],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": [
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "packages/twenty-apps/fireflies/jest.config.mjs",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"coverageReporters": ["text"]
}
}
},
"typecheck": {
"executor": "nx:run-commands",
"options": {
"command": "tsc --noEmit --project {projectRoot}/tsconfig.json"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": [
"{options.outputFile}"
],
"options": {
"lintFilePatterns": [
"packages/twenty-apps/fireflies/**/*.{ts,tsx,js,jsx}"
]
},
"configurations": {
"fix": {
"fix": true
}
}
}
}
}

View file

@ -1,437 +0,0 @@
/**
* 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
*/
/* eslint-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[];
}
const MEETING_FIELDS: FieldDefinition[] = [
{
type: 'RELATION',
name: 'note',
label: 'Meeting Note',
description: 'Related note with detailed meeting content',
icon: 'IconNotes',
isNullable: true,
},
{
type: 'DATE_TIME',
name: 'meetingDate',
label: 'Meeting Date',
description: 'Date and time when the meeting occurred',
icon: 'IconCalendar',
isNullable: true,
},
{
type: 'NUMBER',
name: 'duration',
label: 'Duration (minutes)',
description: 'Meeting duration in minutes',
icon: 'IconClock',
isNullable: true,
},
{
type: 'TEXT',
name: 'meetingType',
label: 'Meeting Type',
description: 'Type of meeting (e.g., Sales Call, Sprint Planning, 1:1)',
icon: 'IconTag',
isNullable: true,
},
{
type: 'TEXT',
name: 'keywords',
label: 'Keywords',
description: 'Key topics and themes discussed (comma-separated)',
icon: 'IconTags',
isNullable: true,
},
{
type: 'NUMBER',
name: 'sentimentScore',
label: 'Sentiment Score',
description: 'Overall meeting sentiment (0-1 scale, where 1 is most positive)',
icon: 'IconMoodSmile',
isNullable: true,
},
{
type: 'NUMBER',
name: 'positivePercent',
label: 'Positive %',
description: 'Percentage of positive sentiment in conversation',
icon: 'IconThumbUp',
isNullable: true,
},
{
type: 'NUMBER',
name: 'negativePercent',
label: 'Negative %',
description: 'Percentage of negative sentiment in conversation',
icon: 'IconThumbDown',
isNullable: true,
},
{
type: 'NUMBER',
name: 'actionItemsCount',
label: 'Action Items',
description: 'Number of action items identified',
icon: 'IconCheckbox',
isNullable: true,
},
{
type: 'LINKS',
name: 'transcriptUrl',
label: 'Transcript URL',
description: 'Link to full transcript in Fireflies',
icon: 'IconFileText',
isNullable: true,
},
{
type: 'LINKS',
name: 'recordingUrl',
label: 'Recording URL',
description: 'Link to video/audio recording in Fireflies',
icon: 'IconVideo',
isNullable: true,
},
{
type: 'TEXT',
name: 'firefliesMeetingId',
label: 'Fireflies Meeting ID',
description: 'Unique identifier from Fireflies',
icon: 'IconKey',
isNullable: true,
},
{
type: 'TEXT',
name: 'organizerEmail',
label: 'Organizer Email',
description: 'Email address of the meeting organizer',
icon: 'IconMail',
isNullable: true,
},
{
type: 'SELECT',
name: 'importStatus',
label: 'Import Status',
description: 'Status of the meeting import from Fireflies',
icon: 'IconCheck',
isNullable: true,
options: [
{ value: 'SUCCESS', label: 'Success', position: 0, color: 'green' },
{ value: 'FAILED', label: 'Failed', position: 1, color: 'red' },
{ value: 'PENDING', label: 'Pending', position: 2, color: 'yellow' },
{ value: 'RETRYING', label: 'Retrying', position: 3, color: 'orange' },
],
},
{
type: 'TEXT',
name: 'importError',
label: 'Import Error',
description: 'Error message if meeting import failed',
icon: 'IconAlertTriangle',
isNullable: true,
},
{
type: 'DATE_TIME',
name: 'lastImportAttempt',
label: 'Last Import Attempt',
description: 'Date and time of the last import attempt',
icon: 'IconClock',
isNullable: true,
},
{
type: 'NUMBER',
name: 'importAttempts',
label: 'Import Attempts',
description: 'Number of times import has been attempted',
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!');
} else if (createdCount > 0) {
console.log('\n✨ Custom fields added successfully!');
console.log('\n📝 Next steps:');
console.log(' 1. Re-sync your app: npx twenty-cli app sync');
console.log(' 2. Update the createMeetingRecord function to use these fields');
console.log(' 3. Test the integration with a real meeting');
}
} 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);
});

View file

@ -1,208 +0,0 @@
/* eslint-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 = 'test-meeting-local-' + Date.now();
const TEST_WEBHOOK_PAYLOAD = {
meetingId: TEST_MEETING_ID,
eventType: 'Transcription completed',
clientReferenceId: 'test-client-ref',
};
// 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 body = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
const signature = generateHMACSignature(body, FIREFLIES_WEBHOOK_SECRET);
console.log('📤 Sending webhook payload:');
console.log(JSON.stringify(TEST_WEBHOOK_PAYLOAD, null, 2));
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));
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);
});

View file

@ -1,19 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
"universalIdentifier": "0765206a-a58d-4ecf-a5a4-23d1d2095f4e",
"name": "receive-fireflies-notes",
"description": "Receives Fireflies webhooks, fetches meeting summaries, and stores them in Twenty.",
"code": {
"src": "serverlessFunctions/receive-fireflies-notes/src/receive-fireflies-notes.ts"
},
"triggers": [
{
"universalIdentifier": "7742d477-4057-436d-9298-565f2934cf1a",
"type": "route",
"path": "/webhook/fireflies",
"httpMethod": "POST",
"isAuthRequired": true
}
]
}

View file

@ -1,442 +0,0 @@
import type { FirefliesMeetingData, FirefliesParticipant, SummaryFetchConfig } from './types';
export class FirefliesApiClient {
private apiKey: string;
constructor(apiKey: string) {
if (!apiKey) {
throw new Error('FIREFLIES_API_KEY is required');
}
this.apiKey = apiKey;
}
async fetchMeetingData(
meetingId: string,
options?: { timeout?: number }
): Promise<FirefliesMeetingData> {
const query = `
query GetTranscript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
meeting_attendees {
displayName
email
phoneNumber
name
location
}
meeting_attendance {
name
join_time
leave_time
}
speakers {
name
}
summary {
action_items
overview
}
transcript_url
}
}
`;
const controller = new AbortController();
const timeoutId = options?.timeout
? setTimeout(() => controller.abort(), options.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?: any };
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);
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
throw error;
}
}
async fetchMeetingDataWithRetry(
meetingId: string,
config: SummaryFetchConfig
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
// immediate_only: single attempt, no retries
if (config.strategy === 'immediate_only') {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] fetching meeting ${meetingId} (strategy: immediate_only)`);
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
const ready = this.isSummaryReady(meetingData);
// eslint-disable-next-line no-console
console.log(`[fireflies-api] summary ready: ${ready}`);
return { data: meetingData, summaryReady: ready };
}
// immediate_with_retry: retry with exponential backoff
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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 });
const ready = this.isSummaryReady(meetingData);
// eslint-disable-next-line no-console
console.log(`[fireflies-api] attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
if (ready) {
return { data: meetingData, summaryReady: true };
}
if (attempt < config.retryAttempts) {
const delayMs = config.retryDelay * attempt;
// eslint-disable-next-line no-console
console.log(`[fireflies-api] summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
await new Promise(resolve => setTimeout(resolve, delayMs));
} else {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] max retries reached, returning partial data`);
return { data: meetingData, summaryReady: false };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
// eslint-disable-next-line no-console
console.error(`[fireflies-api] attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
if (attempt === config.retryAttempts) {
throw error;
}
const delayMs = config.retryDelay * attempt;
// eslint-disable-next-line no-console
console.log(`[fireflies-api] retrying in ${delayMs}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw new Error('Failed to fetch meeting data after retries');
}
private isSummaryReady(meetingData: FirefliesMeetingData): boolean {
return (
(meetingData.summary?.action_items?.length > 0) ||
(meetingData.summary?.overview?.length > 0) ||
meetingData.summary_status === 'completed'
);
}
private extractAllParticipants(transcript: any): FirefliesParticipant[] {
const participantsWithEmails: FirefliesParticipant[] = [];
const participantsNameOnly: FirefliesParticipant[] = [];
// eslint-disable-next-line no-console
console.log('[fireflies-api] === PARTICIPANT EXTRACTION DEBUG ===');
// eslint-disable-next-line no-console
console.log('[fireflies-api] participants field:', JSON.stringify(transcript.participants));
// eslint-disable-next-line no-console
console.log('[fireflies-api] meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
// eslint-disable-next-line no-console
console.log('[fireflies-api] speakers field:', transcript.speakers?.map((s: any) => s.name));
// eslint-disable-next-line no-console
console.log('[fireflies-api] meeting_attendance field:', transcript.meeting_attendance?.map((a: any) => a.name));
// eslint-disable-next-line no-console
console.log('[fireflies-api] organizer_email:', transcript.organizer_email);
// Helper function to check if a string is an email
const isEmail = (str: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
};
// Helper function to check if already exists
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) => {
// Handle comma-separated emails or names
const parts = participant.split(',').map(p => p.trim());
parts.forEach(part => {
const emailMatch = part.match(/<([^>]+)>/);
const email = emailMatch ? emailMatch[1] : '';
const name = part.replace(/<[^>]+>/, '').trim();
// Skip if the "name" is actually an email address
if (isEmail(name)) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] Skipping participant with email as name: "${name}"`);
return;
}
// Skip if empty name
if (!name) {
return;
}
// Skip duplicates
if (isDuplicate(name, email)) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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: any) => {
const name = attendee.displayName || attendee.name || '';
const email = attendee.email || '';
// Skip if name is actually an email
if (isEmail(name)) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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: any) => {
const name = speaker.name || '';
// Skip if name is actually an email
if (isEmail(name)) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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: any) => {
const name = attendance.name || '';
// Skip if name is actually an email or contains comma-separated emails
if (isEmail(name) || name.includes(',')) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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;
if (organizerEmail) {
// Check if organizer email is already in the participants
const existsWithEmail = participantsWithEmails.some(p =>
p.email.toLowerCase() === organizerEmail.toLowerCase()
);
if (!existsWithEmail) {
// Try to find organizer name from speakers/attendance and match with email
let organizerName = '';
// Extract username from organizer email for matching
const emailUsername = organizerEmail.split('@')[0].toLowerCase();
const emailNameVariations = [emailUsername];
// Add common name variations based on email username
if (emailUsername === 'alex') {
emailNameVariations.push('alexander', 'alexandre', 'alex');
}
// Look for organizer in speakers by matching email username to speaker names
if (transcript.speakers && Array.isArray(transcript.speakers)) {
const potentialOrganizerSpeaker = transcript.speakers.find((speaker: any) => {
const name = (speaker.name || '').toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
});
if (potentialOrganizerSpeaker) {
organizerName = potentialOrganizerSpeaker.name;
}
}
// Look for organizer in attendance
if (!organizerName && transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
const potentialOrganizerAttendance = transcript.meeting_attendance.find((attendance: any) => {
const name = (attendance.name || '').toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
});
if (potentialOrganizerAttendance) {
organizerName = potentialOrganizerAttendance.name;
}
}
// If we found a name match, add as participant with email
if (organizerName) {
participantsWithEmails.push({ name: organizerName, email: organizerEmail });
// Remove from name-only participants to avoid duplicates
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 {
// If no name found, add with generic organizer name
participantsWithEmails.push({ name: 'Meeting Organizer', email: organizerEmail });
}
}
}
// Return participants with emails first, then name-only participants
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
// eslint-disable-next-line no-console
console.log('[fireflies-api] === EXTRACTED PARTICIPANTS ===');
// eslint-disable-next-line no-console
console.log('[fireflies-api] With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
// eslint-disable-next-line no-console
console.log('[fireflies-api] Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
// eslint-disable-next-line no-console
console.log('[fireflies-api] Total:', allParticipants.length);
return allParticipants;
}
private transformMeetingData(transcript: any, meetingId: string): FirefliesMeetingData {
// Convert date to ISO string - handle both timestamp and ISO string formats
let dateString: string;
if (transcript.date) {
if (typeof transcript.date === 'number') {
// Unix timestamp in milliseconds
dateString = new Date(transcript.date).toISOString();
} else if (typeof transcript.date === 'string') {
// Could be ISO string or timestamp string
const parsed = Number(transcript.date);
if (!isNaN(parsed)) {
// It's a numeric string (timestamp)
dateString = new Date(parsed).toISOString();
} else {
// It's already an ISO string
dateString = transcript.date;
}
} else {
dateString = new Date().toISOString();
}
} else {
dateString = new Date().toISOString();
}
return {
id: transcript.id || meetingId,
title: transcript.title || 'Untitled Meeting',
date: dateString,
duration: transcript.duration || 0,
participants: this.extractAllParticipants(transcript),
organizer_email: transcript.organizer_email,
summary: {
action_items: Array.isArray(transcript.summary?.action_items)
? transcript.summary.action_items
: (typeof transcript.summary?.action_items === 'string'
? [transcript.summary.action_items]
: []),
overview: transcript.summary?.overview || '',
keywords: transcript.summary?.keywords,
topics_discussed: transcript.summary?.topics_discussed,
meeting_type: transcript.summary?.meeting_type,
},
analytics: transcript.sentiments ? {
sentiments: {
positive_pct: transcript.sentiments.positive_pct || 0,
negative_pct: transcript.sentiments.negative_pct || 0,
neutral_pct: transcript.sentiments.neutral_pct || 0,
}
} : undefined,
transcript_url: transcript.transcript_url || `https://app.fireflies.ai/view/${meetingId}`,
recording_url: transcript.video_url || undefined,
summary_status: transcript.summary_status,
};
}
}

View file

@ -1,227 +0,0 @@
import type { FirefliesMeetingData, MeetingCreateInput } from './types';
export class MeetingFormatter {
static formatNoteBody(meetingData: FirefliesMeetingData): string {
const meetingDate = new Date(meetingData.date);
const formattedDate = meetingDate.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
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`;
}
// Key topics
if (meetingData.summary?.topics_discussed && Array.isArray(meetingData.summary.topics_discussed) && meetingData.summary.topics_discussed.length > 0) {
noteBody += `\n## Key Topics\n`;
meetingData.summary.topics_discussed.forEach(topic => {
noteBody += `- ${topic}\n`;
});
}
// Action items
if (meetingData.summary?.action_items && Array.isArray(meetingData.summary.action_items) && meetingData.summary.action_items.length > 0) {
noteBody += `\n## Action Items\n`;
meetingData.summary.action_items.forEach(item => {
noteBody += `- ${item}\n`;
});
}
// Insights section
noteBody += `\n## Insights\n`;
if (meetingData.summary?.keywords && Array.isArray(meetingData.summary.keywords) && meetingData.summary.keywords.length > 0) {
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`;
}
// Resources section
noteBody += `\n## Resources\n`;
noteBody += `[View Full Transcript](${meetingData.transcript_url})\n`;
if (meetingData.recording_url) {
noteBody += `[Watch Recording](${meetingData.recording_url})\n`;
}
return noteBody;
}
static formatMeetingNotes(meetingData: FirefliesMeetingData): string {
const meetingDate = new Date(meetingData.date);
const formattedDate = meetingDate.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const durationMinutes = Math.round(meetingData.duration);
let meetingNotes = `**Date:** ${formattedDate}\n`;
meetingNotes += `**Duration:** ${durationMinutes} minutes\n`;
if (meetingData.participants.length > 0) {
const participantNames = meetingData.participants.map(p => p.name).join(', ');
meetingNotes += `**Participants:** ${participantNames}\n`;
}
// Overview section
if (meetingData.summary?.overview) {
meetingNotes += `\n## Overview\n${meetingData.summary.overview}\n`;
}
// Key topics
if (meetingData.summary?.topics_discussed && Array.isArray(meetingData.summary.topics_discussed) && meetingData.summary.topics_discussed.length > 0) {
meetingNotes += `\n## Key Topics\n`;
meetingData.summary.topics_discussed.forEach(topic => {
meetingNotes += `- ${topic}\n`;
});
}
// Action items
if (meetingData.summary?.action_items && Array.isArray(meetingData.summary.action_items) && meetingData.summary.action_items.length > 0) {
meetingNotes += `\n## Action Items\n`;
meetingData.summary.action_items.forEach(item => {
meetingNotes += `- ${item}\n`;
});
}
// Insights section
meetingNotes += `\n## Insights\n`;
if (meetingData.summary?.keywords && Array.isArray(meetingData.summary.keywords) && meetingData.summary.keywords.length > 0) {
meetingNotes += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
}
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
meetingNotes += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
}
if (meetingData.summary?.meeting_type) {
meetingNotes += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
}
// Resources section
meetingNotes += `\n## Resources\n`;
meetingNotes += `[View Full Transcript](${meetingData.transcript_url})\n`;
if (meetingData.recording_url) {
meetingNotes += `[Watch Recording](${meetingData.recording_url})\n`;
}
return meetingNotes;
}
static toMeetingCreateInput(
meetingData: FirefliesMeetingData,
noteId?: string
): MeetingCreateInput {
const durationMinutes = Math.round(meetingData.duration);
// 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;
}
// Only add optional fields if they have values
if (meetingData.summary?.meeting_type) {
input.meetingType = meetingData.summary.meeting_type;
}
if (meetingData.summary?.keywords && Array.isArray(meetingData.summary.keywords) && meetingData.summary.keywords.length > 0) {
input.keywords = meetingData.summary.keywords.join(', ');
}
if (meetingData.analytics?.sentiments?.positive_pct) {
input.sentimentScore = meetingData.analytics.sentiments.positive_pct / 100;
input.positivePercent = meetingData.analytics.sentiments.positive_pct;
}
if (meetingData.analytics?.sentiments?.negative_pct) {
input.negativePercent = meetingData.analytics.sentiments.negative_pct;
}
// Only add URLs if they are valid (not empty strings)
if (meetingData.transcript_url && meetingData.transcript_url.trim()) {
input.transcriptUrl = {
primaryLinkUrl: meetingData.transcript_url,
primaryLinkLabel: 'View Transcript'
};
}
if (meetingData.recording_url && meetingData.recording_url.trim()) {
input.recordingUrl = {
primaryLinkUrl: meetingData.recording_url,
primaryLinkLabel: 'Watch Recording'
};
}
if (meetingData.organizer_email) {
input.organizerEmail = meetingData.organizer_email;
}
// Set success status and timestamps
input.importStatus = '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,
};
}
}

View file

@ -1,10 +0,0 @@
export { config, main } from './receive-fireflies-notes';
export type {
FirefliesMeetingData,
FirefliesParticipant,
FirefliesWebhookPayload,
ProcessResult,
SummaryFetchConfig,
SummaryStrategy
} from './types';

View file

@ -1,28 +0,0 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
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: ServerlessFunctionConfig = {
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: true,
},
],
};

View file

@ -1,575 +0,0 @@
import type {
Contact,
CreateMeetingResponse,
CreateNoteResponse,
CreatePersonResponse,
FindMeetingResponse,
FindPeopleResponse,
FirefliesParticipant,
GraphQLResponse,
IdNode,
MeetingCreateInput,
} from './types';
export class TwentyCrmService {
private apiKey: string;
private apiUrl: string;
private isTestEnvironment: boolean;
constructor(apiKey: string, apiUrl: string) {
if (!apiKey) {
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 matchParticipantsToContacts(
participants: FirefliesParticipant[],
): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
if (participants.length === 0) {
return { matchedContacts: [], unmatchedParticipants: [] };
}
// Split participants into those with emails and those with names only
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[] = [];
// 1. Match by email first
if (participantsWithEmails.length > 0) {
const emailMatches = await this.matchByEmail(participantsWithEmails);
matchedContacts.push(...emailMatches.matchedContacts);
unmatchedParticipants.push(...emailMatches.unmatchedParticipants);
}
// 2. For participants without emails, try name-based matching
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[] = [];
// Get set of already matched contact IDs to avoid duplicates
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(' ');
// Try exact name match first
let query = `
query FindPeopleByName($firstName: String!, $lastName: String) {
people(filter: {
and: [
{ name: { firstName: { eq: $firstName } } }
${lastName ? '{ name: { lastName: { eq: $lastName } } }' : ''}
]
}) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`;
let variables: any = { firstName };
if (lastName) {
variables.lastName = lastName;
}
try {
const response = await this.gqlRequest<any>(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 no exact match and we have a last name, try fuzzy matching
if (lastName) {
query = `
query FindPeopleByNameFuzzy($firstName: String!) {
people(filter: { name: { firstName: { ilike: $firstName } } }) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`;
const fuzzyResponse = await this.gqlRequest<any>(query, { firstName: `%${firstName}%` });
const fuzzyPeople = fuzzyResponse.data?.people?.edges;
if (fuzzyPeople && fuzzyPeople.length > 0) {
// Find best match by checking if last name contains our target
const bestMatch = fuzzyPeople.find((edge: any) => {
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 {
// Silently fail - don't break the entire process for a single contact lookup
return null;
}
}
async createContactsForUnmatched(
participants: FirefliesParticipant[],
): Promise<string[]> {
const newContactIds: string[] = [];
// Split participants into those with emails and those with names only
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
// Process participants with emails (original logic)
if (participantsWithEmails.length > 0) {
const emailContactIds = await this.createContactsWithEmails(participantsWithEmails);
newContactIds.push(...emailContactIds);
}
// Process participants with names only (new logic)
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[] = [];
// Deduplicate participants by email to prevent duplicate contact creation
const uniqueParticipants = participants.reduce<FirefliesParticipant[]>((unique, participant) => {
const existing = unique.find(p => p.email === participant.email);
if (!existing) {
unique.push(participant);
} else {
// eslint-disable-next-line no-console
console.warn(`[fireflies] 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);
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')) {
// eslint-disable-next-line no-console
console.warn(`[fireflies] 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[] = [];
// Deduplicate participants by name to prevent duplicate contact creation
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 {
// eslint-disable-next-line no-console
console.warn(`[fireflies] Duplicate participant name detected: ${participant.name}. Using first occurrence.`);
}
return unique;
}, []);
for (const participant of uniqueParticipants) {
// Check if we already have a contact with this exact name to avoid duplicates
const existingContact = await this.findContactByName(participant.name);
if (existingContact) {
// eslint-disable-next-line no-console
console.warn(`[fireflies] 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 },
// Note: We don't set emails for name-only participants
// This will create a contact without an email address
},
};
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);
// eslint-disable-next-line no-console
console.log(`[fireflies] Created contact for name-only participant: ${participant.name}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// eslint-disable-next-line no-console
console.warn(`[fireflies] Failed to create contact for ${participant.name}: ${errorMessage}`);
// Continue processing other participants instead of failing completely
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<any>(mutation, variables);
}
async createMeetingTarget(meetingId: string, contactId: string): Promise<void> {
const mutation = `
mutation CreateMeetingTarget($data: NoteTargetCreateInput!) {
createNoteTarget(data: $data) {
id
meetingId
personId
}
}
`;
const variables = {
data: {
meetingId,
personId: contactId,
},
};
await this.gqlRequest<any>(mutation, variables);
}
async createMeeting(meetingData: MeetingCreateInput): Promise<string> {
const mutation = `
mutation CreateMeeting($data: MeetingCreateInput!) {
createMeeting(data: $data) { id }
}
`;
const variables = { data: meetingData };
// Debug: log the variables being sent
if (!this.isTestEnvironment) {
// eslint-disable-next-line no-console
console.log('[fireflies] 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>
): 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) {
// eslint-disable-next-line no-console
console.error('[twenty-crm] 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) {
// eslint-disable-next-line no-console
console.log('[fireflies] 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<any[]> {
const query = `
query FindFailedMeetings {
meetings(filter: { importStatus: { eq: "FAILED" } }) {
edges {
node {
id
name
firefliesMeetingId
importError
lastImportAttempt
importAttempts
createdAt
}
}
}
}
`;
const response = await this.gqlRequest<any>(query);
return response.data?.meetings?.edges?.map((edge: any) => 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<any>(mutation, variables);
}
}

View file

@ -1,130 +0,0 @@
// Fireflies API Types
export type FirefliesParticipant = {
email: string;
name: string;
};
export type FirefliesWebhookPayload = {
meetingId: string;
eventType: string;
clientReferenceId?: string;
};
export type FirefliesMeetingData = {
id: string;
title: string;
date: string;
duration: number;
participants: FirefliesParticipant[];
organizer_email?: string;
summary: {
action_items: string[];
keywords?: string[];
overview: string;
gist?: string;
topics_discussed?: string[];
meeting_type?: string;
bullet_gist?: string;
};
analytics?: {
sentiments?: {
positive_pct: number;
negative_pct: number;
neutral_pct: number;
};
};
transcript_url: string;
recording_url?: string;
summary_status?: string;
};
// 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 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;
sentimentScore?: 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;
};
export type MeetingCreateInput = {
name: string;
noteId?: string | null; // This is the relation field
meetingDate: string;
duration: number;
meetingType?: string | null;
keywords?: string | null;
sentimentScore?: number | null;
positivePercent?: number | null;
negativePercent?: number | null;
actionItemsCount: number;
transcriptUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
recordingUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
firefliesMeetingId: string;
organizerEmail?: string | null;
importStatus?: 'SUCCESS' | 'FAILED' | 'PENDING' | 'RETRYING' | null;
importError?: string | null;
lastImportAttempt?: string | null;
importAttempts?: number | null;
};

View file

@ -1,30 +0,0 @@
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';
};
import type { SummaryFetchConfig, SummaryStrategy } from './types';
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);
};

View file

@ -1,356 +0,0 @@
/* eslint-disable no-console */
import { FirefliesApiClient } from './fireflies-api-client';
import { MeetingFormatter } from './formatters';
import { TwentyCrmService } from './twenty-crm-service';
import type { FirefliesWebhookPayload, ProcessResult } from './types';
import { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
import {
getWebhookSecretFingerprint,
isValidFirefliesPayload,
verifyWebhookSignature
} from './webhook-validator';
declare const process: { env: Record<string, string | undefined> };
export class WebhookHandler {
private debug: string[] = [];
private isTestEnvironment: boolean;
constructor() {
this.isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
}
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
const result: ProcessResult = {
success: false,
noteIds: [],
newContacts: [],
errors: [],
};
try {
this.logDebug('[fireflies] invoked');
this.logDebug(`[fireflies] apiUrl=${getApiUrl()}`);
// 0) Validate environment configuration
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!firefliesApiKey) {
this.logError('[fireflies] FIREFLIES_API_KEY not configured');
throw new Error('FIREFLIES_API_KEY environment variable is required');
}
if (!twentyApiKey) {
this.logError('[fireflies] TWENTY_API_KEY not configured');
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.logDebug(`[fireflies] payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
// 2) Verify webhook signature
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
this.logDebug(`[fireflies] webhook secret fingerprint=${secretFingerprint}`);
this.verifySignature(payload, finalHeaders, webhookSecret);
this.logDebug('[fireflies] signature verification: ok');
// 3) Fetch meeting data from Fireflies
const summaryConfig = getSummaryFetchConfig();
this.logDebug(`[fireflies] summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
this.logDebug(`[fireflies] fetching meeting data from Fireflies API`);
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
payload.meetingId,
summaryConfig
);
this.logDebug(`[fireflies] meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
result.summaryReady = summaryReady;
result.summaryPending = !summaryReady;
// Extract business intelligence
if (summaryReady) {
result.actionItemsCount = meetingData.summary.action_items.length;
result.keyTopics = meetingData.summary.topics_discussed;
result.meetingType = meetingData.summary.meeting_type;
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
result.sentimentScore = sentiments.positive_pct / 100;
}
}
// 4) Check for duplicate meetings
const twentyService = new TwentyCrmService(
twentyApiKey,
getApiUrl()
);
const existingMeeting = await twentyService.findExistingMeeting(meetingData.title);
if (existingMeeting) {
this.logDebug(`[fireflies] meeting already exists id=${existingMeeting.id}`);
result.success = true;
result.meetingId = existingMeeting.id;
result.debug = this.debug;
return result;
}
this.logDebug('[fireflies] no existing meeting found, proceeding');
// 5) Match participants to existing contacts
this.logDebug(`[fireflies] total participants from API: ${meetingData.participants.length}`);
meetingData.participants.forEach((p, idx) => {
this.logDebug(`[fireflies] participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
meetingData.participants
);
this.logDebug(`[fireflies] matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
unmatchedParticipants.forEach((p, idx) => {
this.logDebug(`[fireflies] 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.logDebug(`[fireflies] 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.logDebug(`[fireflies] created note id=${noteId}`);
// 8) Create meeting with direct relationship to the note
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
this.logDebug(`[fireflies] meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
result.meetingId = await twentyService.createMeeting(meetingInput);
this.logDebug(`[fireflies] 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.logDebug(`[fireflies] linked note to ${allContactIds.length} participants`);
result.success = true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logError(`[fireflies] error: ${message}`);
result.errors?.push(message);
// Try to create a failed meeting record for tracking
await this.createFailedMeetingRecord(params, message);
}
result.debug = this.debug;
return result;
}
private parsePayload(params: unknown): { payload: FirefliesWebhookPayload; extractedHeaders?: Record<string, string> } {
let normalizedParams = params;
let extractedHeaders: Record<string, string> | undefined;
// Handle string-encoded params
if (typeof normalizedParams === 'string') {
this.logDebug(`[fireflies] 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.logDebug(`[fireflies] parsed params keys: ${parsedKeys.join(',') || 'none'}`);
}
} catch (parseError) {
this.logError(`[fireflies] 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.logDebug(`[fireflies] 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.logDebug(`[fireflies] detected payload under wrapper key "${key}"`);
payload = candidate as FirefliesWebhookPayload;
break;
}
}
}
if (!payload) {
this.logError('[fireflies] 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.logDebug(`[fireflies] 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.logDebug(`[fireflies] header keys: ${headerKeys.join(',')}`);
}
const headerSignature = Object.entries(normalizedHeaders).find(
([key]) => key.toLowerCase() === 'x-hub-signature',
)?.[1];
const payloadRecord = payload as Record<string, unknown>;
const payloadSignature =
typeof payloadRecord['x-hub-signature'] === 'string'
? (payloadRecord['x-hub-signature'] as string)
: undefined;
if (payloadSignature) {
this.logDebug('[fireflies] found signature inside payload');
}
const signature =
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
const body = typeof normalizedHeaders['body'] === 'string'
? normalizedHeaders['body']
: JSON.stringify(payloadRecord);
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
if (!signatureCheck.isValid) {
this.logDebug(
`[fireflies] signature check failed. headerPresent=${Boolean(
headerSignature,
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
);
if (signature) {
this.logDebug(`[fireflies] provided signature=${signature}`);
} else {
this.logDebug('[fireflies] provided signature=undefined');
}
this.logDebug(
`[fireflies] computed signature=${signatureCheck.computedSignature ?? 'unavailable'}`,
);
this.logError('[fireflies] error: Invalid webhook signature');
throw new Error('Invalid webhook signature');
}
}
private async linkNoteToParticipants(
twentyService: TwentyCrmService,
noteId: string,
contactIds: string[]
): Promise<void> {
// Create Note-Person links for each participant
for (const contactId of contactIds) {
try {
await twentyService.createNoteTarget(noteId, contactId);
this.logDebug(`[fireflies] linked note ${noteId} to person ${contactId}`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logError(`[fireflies] failed to link note to person ${contactId}: ${message}`);
// Continue with other participants
}
}
}
private logDebug(message: string): void {
this.debug.push(message);
if (!this.isTestEnvironment) {
console.log(message);
}
}
private logError(message: string): void {
this.debug.push(message);
if (!this.isTestEnvironment) {
console.error(message);
}
}
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
try {
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!twentyApiKey) {
this.logDebug('[fireflies] Cannot create failed meeting record: TWENTY_API_KEY not configured');
return;
}
// Try to extract meeting ID and title from the params
let meetingId = 'unknown';
let meetingTitle = 'Unknown Meeting';
const { payload } = this.parsePayload(params);
if (payload?.meetingId) {
meetingId = payload.meetingId;
// Try to get meeting title from Fireflies API if possible
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
if (firefliesApiKey) {
try {
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
meetingTitle = meetingData.title || meetingTitle;
} catch (fetchError) {
this.logDebug(`[fireflies] 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.logDebug(`[fireflies] Created failed meeting record: ${failedMeetingId}`);
} catch (recordError) {
// Don't throw here - we don't want to break the original error handling
this.logError(`[fireflies] Failed to create failed meeting record: ${recordError instanceof Error ? recordError.message : 'Unknown error'}`);
}
}
}

View file

@ -1,54 +0,0 @@
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')
);
};

View file

@ -1,712 +0,0 @@
import * as crypto from 'crypto';
import {
main,
type FirefliesMeetingData,
type FirefliesWebhookPayload,
} from '../actions/receive-fireflies-notes';
// 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',
},
sentiments: { // Note: Raw API has sentiments at top level, not in analytics
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',
recording_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.sentimentScore).toBeCloseTo(0.75, 2); // Use toBeCloseTo for floating point comparison
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?: any) => {
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 body = options?.body ? JSON.parse(options.body) : {};
if (body.query?.includes('createNote')) {
createNoteMock(body.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]');
});
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?: any) => {
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 body = options?.body ? JSON.parse(options.body) : {};
if (body.query?.includes('createMeeting')) {
createMeetingMock(body.variables);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { createMeeting: { id: 'new-meeting-id' } },
}),
});
}
if (body.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 any, {});
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 any, {});
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
});
});

View file

@ -1,16 +0,0 @@
// 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.DEBUG_LOGS = 'true'; // Enable debug logs in tests
// Reset mocks before each test
beforeEach(() => {
jest.clearAllMocks();
});

View file

@ -1,11 +0,0 @@
export {
config,
main,
type FirefliesMeetingData,
type FirefliesParticipant,
type FirefliesWebhookPayload,
type ProcessResult,
type SummaryFetchConfig,
type SummaryStrategy
} from '../../serverlessFunctions/receive-fireflies-notes/src/index';

View file

@ -1,14 +0,0 @@
import { ObjectMetadata } from 'twenty-sdk/application';
@ObjectMetadata({
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 {}

View file

@ -1,27 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}

File diff suppressed because it is too large Load diff

View file

@ -2,13 +2,6 @@
"compileOnSave": false,
"compilerOptions": {
"rootDir": ".",
"baseUrl": ".",
"paths": {
"twenty-sdk": ["packages/twenty-sdk/src/index.ts"],
"twenty-sdk/*": ["packages/twenty-sdk/src/*"],
"@/application": ["packages/twenty-sdk/src/application/index.ts"],
"@/application/*": ["packages/twenty-sdk/src/application/*"]
},
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",