Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7ed823d3e | ||
|
|
37200a1c3b | ||
|
|
bc8c9eabeb | ||
|
|
c415660640 | ||
|
|
ad75662919 | ||
|
|
aa7c5a778b | ||
|
|
8d43ed4515 | ||
|
|
3d1d3d9f04 | ||
|
|
b19dc703cc | ||
|
|
9b668619c5 | ||
|
|
e7c83dc04f | ||
|
|
693fe01fa4 | ||
|
|
c616713435 | ||
|
|
859c948d01 |
@@ -12,7 +12,6 @@ This directory contains Twenty's development guidelines and best practices in th
|
||||
### Core Guidelines
|
||||
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
|
||||
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
|
||||
- **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files)
|
||||
|
||||
### Code Quality
|
||||
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
|
||||
@@ -41,7 +40,7 @@ You can manually reference any rule using the `@ruleName` syntax:
|
||||
- `@testing-guidelines` - Get testing recommendations
|
||||
|
||||
### Rule Types Used
|
||||
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
|
||||
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
|
||||
- **Auto Attached** - Loaded when matching file patterns are referenced
|
||||
- **Agent Requested** - Available for AI to include when relevant
|
||||
- **Manual** - Only included when explicitly mentioned
|
||||
@@ -56,9 +55,7 @@ npx nx storybook:build twenty-front # Build Storybook
|
||||
npx nx storybook:serve-and-test:static # Run Storybook tests
|
||||
|
||||
# Development
|
||||
npx nx lint:diff-with-main twenty-front # Lint files changed vs main (fastest)
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
|
||||
npx nx lint twenty-front # Lint all files (slower)
|
||||
npx nx lint twenty-front # Run linter
|
||||
npx nx typecheck twenty-front # Type checking
|
||||
npx nx run twenty-front:graphql:generate # Generate GraphQL types
|
||||
```
|
||||
@@ -72,9 +69,7 @@ npx nx run twenty-server:database:migrate:prod # Run migrations
|
||||
|
||||
# Development
|
||||
npx nx run twenty-server:start # Start the server
|
||||
npx nx lint:diff-with-main twenty-server # Lint files changed vs main (fastest)
|
||||
npx nx lint:diff-with-main twenty-server --configuration=fix # Auto-fix changed files
|
||||
npx nx run twenty-server:lint # Lint all files (slower)
|
||||
npx nx run twenty-server:lint # Run linter (add --fix to auto-fix)
|
||||
npx nx run twenty-server:typecheck # Type checking
|
||||
npx nx run twenty-server:test # Run unit tests
|
||||
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
|
||||
|
||||
@@ -89,8 +89,6 @@ Use the AI codebase search to find:
|
||||
|
||||
### 1. Setup Git Branch
|
||||
|
||||
**IMPORTANT**: Always start from an up-to-date main branch to avoid merge conflicts and ensure the changelog is based on the latest code.
|
||||
|
||||
```bash
|
||||
cd /Users/thomascolasdesfrancs/code/twenty
|
||||
git checkout main
|
||||
@@ -100,8 +98,6 @@ git checkout -b {VERSION}
|
||||
|
||||
Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
|
||||
|
||||
⚠️ **Do this first** before making any file changes. This ensures your branch is based on the latest main.
|
||||
|
||||
### 2. Create File Structure
|
||||
|
||||
**Create changelog file:**
|
||||
@@ -178,7 +174,6 @@ Description of the third feature.
|
||||
- Focus on user benefits, not technical implementation
|
||||
- Use active voice
|
||||
- Start with what the user can now do
|
||||
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
|
||||
|
||||
**Reference Previous Changelogs:**
|
||||
- Check `packages/twenty-website/src/content/releases/` for examples
|
||||
|
||||
@@ -12,12 +12,7 @@ alwaysApply: true
|
||||
npx nx run twenty-front:build
|
||||
npx nx run twenty-server:test
|
||||
|
||||
# Lint diff with main (recommended - much faster!)
|
||||
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
|
||||
npx nx lint:diff-with-main twenty-server
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
|
||||
|
||||
# Run target for all projects (slower)
|
||||
# Run target for all projects
|
||||
npx nx run-many --target=build --all
|
||||
npx nx run-many --target=test --projects=twenty-front,twenty-server
|
||||
|
||||
@@ -48,18 +43,12 @@ npx nx g @nx/react:component my-component
|
||||
}
|
||||
```
|
||||
|
||||
## Linting Strategy
|
||||
For faster development, always prefer linting only changed files:
|
||||
- Use `npx nx lint:diff-with-main <project>` to lint only files changed vs main branch
|
||||
- Use `--configuration=fix` to auto-fix issues in changed files
|
||||
- Only use `npx nx lint <project>` when you need to lint the entire project
|
||||
|
||||
## Dependency Graph
|
||||
```bash
|
||||
# View project dependencies
|
||||
npx nx graph
|
||||
|
||||
# Check what's affected by changes (runs target on affected projects)
|
||||
# Check what's affected by changes
|
||||
npx nx affected --target=test
|
||||
npx nx affected --target=build --base=main
|
||||
```
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
description: Guidelines for generating and managing TypeORM migrations in twenty-server
|
||||
globs: [
|
||||
"packages/twenty-server/src/**/*.entity.ts",
|
||||
"packages/twenty-server/src/database/typeorm/**/*.ts"
|
||||
]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
## Server Migrations (twenty-server)
|
||||
|
||||
- **When changing an entity, always generate a migration**
|
||||
- If you modify a `*.entity.ts` file in `packages/twenty-server/src`, you **must** generate a corresponding TypeORM migration instead of manually editing the database schema.
|
||||
- Use the Nx + TypeORM command from the project root:
|
||||
|
||||
```bash
|
||||
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
|
||||
```
|
||||
|
||||
- Replace `[name]` with a descriptive, kebab-case migration name that reflects the change (for example, `add-agent-turn-evaluation`).
|
||||
|
||||
- **Prefer generated migrations over manual edits**
|
||||
- Let TypeORM infer schema changes from the updated entities; only adjust the generated migration file manually if absolutely necessary (for example, for data backfills or complex constraints).
|
||||
- Keep schema changes (DDL) in these generated migrations and avoid mixing in heavy data migrations unless there is a strong reason and clear comments.
|
||||
|
||||
- **Keep migrations consistent and reversible**
|
||||
- Ensure the generated migration includes both `up` and `down` logic that correctly applies and reverts the entity change when possible.
|
||||
- Do not delete or rewrite existing, committed migrations unless you are explicitly working on a pre-release branch where history rewrites are allowed by team conventions.
|
||||
|
||||
@@ -63,4 +63,4 @@ git push origin your-branch-name
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you face any issues or have suggestions, please feel free to [create an issue on Twenty's GitHub repository](https://github.com/twentyhq/twenty/issues/new). Please provide as much detail as possible.
|
||||
If you face any issues or have suggestions, please feel free to (create an issue on Twenty's GitHub repository)[https://github.com/twentyhq/twenty/issues/new]. Please provide as much detail as possible.
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ runs:
|
||||
id: cache-primary-key-builder
|
||||
shell: bash
|
||||
run: |
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=v3-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Restore cache
|
||||
uses: actions/cache/restore@v4
|
||||
id: restore-cache
|
||||
@@ -32,4 +32,4 @@ runs:
|
||||
.nx
|
||||
node_modules/.cache
|
||||
packages/*/node_modules/.cache
|
||||
${{ inputs.additional-paths }}
|
||||
${{ inputs.additional-paths }}
|
||||
@@ -63,9 +63,9 @@ jobs:
|
||||
- 8123:8123
|
||||
- 9000:9000
|
||||
options: >-
|
||||
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
@@ -78,12 +78,12 @@ jobs:
|
||||
id: merge_attempt
|
||||
run: |
|
||||
echo "Attempting to merge main into current branch..."
|
||||
|
||||
|
||||
git fetch origin main
|
||||
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
|
||||
|
||||
if git merge origin/main --no-edit; then
|
||||
echo "✅ Successfully merged main into current branch"
|
||||
echo "merged=true" >> $GITHUB_OUTPUT
|
||||
@@ -91,16 +91,16 @@ jobs:
|
||||
else
|
||||
echo "❌ Merge failed due to conflicts"
|
||||
echo "⚠️ Falling back to comparing current branch against main without merge"
|
||||
|
||||
|
||||
# Abort the failed merge
|
||||
git merge --abort
|
||||
|
||||
|
||||
echo "merged=false" >> $GITHUB_OUTPUT
|
||||
echo "BRANCH_STATE=conflicts" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build shared dependencies
|
||||
run: |
|
||||
@@ -128,22 +128,22 @@ jobs:
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
|
||||
echo "" >> "$env_file"
|
||||
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/current_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.CURRENT_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
@@ -166,19 +166,19 @@ jobs:
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Current branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
|
||||
echo "Current branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for current branch server to start"
|
||||
echo "Current server log:"
|
||||
@@ -188,15 +188,15 @@ jobs:
|
||||
|
||||
- name: Download GraphQL and REST responses from current branch
|
||||
run: |
|
||||
# Read admin token from shared test tokens file (single source of truth)
|
||||
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
|
||||
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
|
||||
echo "Downloading GraphQL schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
-o current-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
echo "Downloading GraphQL metadata schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -214,32 +214,32 @@ jobs:
|
||||
-o current-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
# Download current branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from current server..."
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
# Verify the downloads
|
||||
echo "Current branch files downloaded:"
|
||||
ls -la current-*
|
||||
|
||||
|
||||
|
||||
- name: Preserve current branch files
|
||||
run: |
|
||||
# Create a temp directory to store current branch files
|
||||
mkdir -p /tmp/current-branch-files
|
||||
|
||||
|
||||
# Move current branch files to temp directory
|
||||
mv current-* /tmp/current-branch-files/ 2>/dev/null || echo "No current-* files to preserve"
|
||||
|
||||
|
||||
echo "Preserved current branch files for later restoration"
|
||||
|
||||
- name: Stop current branch server
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
rm -rf node_modules packages/*/node_modules packages/*/dist dist .nx/cache
|
||||
|
||||
- name: Install dependencies for main branch
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build main branch dependencies
|
||||
run: |
|
||||
@@ -281,22 +281,22 @@ jobs:
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
|
||||
echo "" >> "$env_file"
|
||||
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
@@ -319,19 +319,19 @@ jobs:
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Main branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
|
||||
echo "Main branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for main branch server to start"
|
||||
echo "Main server log:"
|
||||
@@ -341,15 +341,15 @@ jobs:
|
||||
|
||||
- name: Download GraphQL and REST responses from main branch
|
||||
run: |
|
||||
# Read admin token from shared test tokens file (single source of truth)
|
||||
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
|
||||
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
|
||||
echo "Downloading GraphQL schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -358,7 +358,7 @@ jobs:
|
||||
-o main-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
echo "Downloading GraphQL metadata schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -367,33 +367,33 @@ jobs:
|
||||
-o main-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
# Download main branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from main server..."
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
# Verify the downloads
|
||||
echo "Main branch files downloaded:"
|
||||
ls -la main-*
|
||||
|
||||
|
||||
|
||||
- name: Restore current branch files
|
||||
run: |
|
||||
# Move current branch files back to working directory
|
||||
mv /tmp/current-branch-files/* . 2>/dev/null || echo "No files to restore"
|
||||
|
||||
|
||||
# Verify all files are present
|
||||
echo "All API files restored:"
|
||||
ls -la current-* main-* 2>/dev/null || echo "Some files may be missing"
|
||||
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf /tmp/current-branch-files
|
||||
|
||||
@@ -406,9 +406,9 @@ jobs:
|
||||
run: |
|
||||
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
|
||||
npm install -g @graphql-inspector/cli
|
||||
|
||||
|
||||
echo "=== GENERATING GRAPHQL DIFF REPORTS ==="
|
||||
|
||||
|
||||
# Check if GraphQL schema has changes
|
||||
echo "Checking GraphQL schema for changes..."
|
||||
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
echo "\`\`\`" >> graphql-schema-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
|
||||
# Check if GraphQL metadata schema has changes
|
||||
echo "Checking GraphQL metadata schema for changes..."
|
||||
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
|
||||
@@ -444,7 +444,7 @@ jobs:
|
||||
echo "\`\`\`" >> graphql-metadata-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
|
||||
# Show summary
|
||||
echo "Generated diff files:"
|
||||
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
|
||||
@@ -452,32 +452,32 @@ jobs:
|
||||
- name: Check REST API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
|
||||
|
||||
|
||||
# Use the Java-based openapi-diff via Docker
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-api-diff.json \
|
||||
/specs/main-rest-api.json /specs/current-rest-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-api-diff.json" ] && jq empty rest-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-api-diff.json)
|
||||
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST API"
|
||||
|
||||
|
||||
# Generate breaking changes report
|
||||
echo "# REST API Breaking Changes" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
@@ -493,7 +493,7 @@ jobs:
|
||||
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
|
||||
else "" end
|
||||
' rest-api-diff.json >> rest-api-diff.md
|
||||
|
||||
|
||||
elif [ "$different" = "true" ]; then
|
||||
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed) - no PR comment will be posted"
|
||||
# Don't create markdown file for non-breaking changes to avoid PR comments
|
||||
@@ -503,7 +503,7 @@ jobs:
|
||||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the files"
|
||||
|
||||
|
||||
echo "# REST API Analysis Error" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST API changes**" >> rest-api-diff.md
|
||||
@@ -512,7 +512,7 @@ jobs:
|
||||
echo "\`\`\`" >> rest-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-api.json /specs/current-rest-api.json 2>&1 >> rest-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-api-diff.md
|
||||
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST API analysis tool error - continuing workflow"
|
||||
fi
|
||||
@@ -520,33 +520,33 @@ jobs:
|
||||
- name: Check REST Metadata API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
|
||||
|
||||
|
||||
# Use the Java-based openapi-diff for metadata API as well
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-metadata-api-diff.json \
|
||||
/specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-metadata-api-diff.json" ] && jq empty rest-metadata-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-metadata-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-metadata-api-diff.json)
|
||||
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST Metadata API"
|
||||
|
||||
|
||||
# Generate breaking changes report (only for breaking changes)
|
||||
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
|
||||
@@ -570,7 +570,7 @@ jobs:
|
||||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the metadata API files"
|
||||
|
||||
|
||||
echo "# REST Metadata API Analysis Error" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST Metadata API changes**" >> rest-metadata-api-diff.md
|
||||
@@ -579,7 +579,7 @@ jobs:
|
||||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json 2>&1 >> rest-metadata-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
|
||||
fi
|
||||
@@ -592,7 +592,7 @@ jobs:
|
||||
const fs = require('fs');
|
||||
let hasChanges = false;
|
||||
let comment = '';
|
||||
|
||||
|
||||
try {
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
@@ -604,7 +604,7 @@ jobs:
|
||||
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.trim()) {
|
||||
@@ -615,7 +615,7 @@ jobs:
|
||||
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.trim()) {
|
||||
@@ -626,7 +626,7 @@ jobs:
|
||||
comment += restDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.trim()) {
|
||||
@@ -637,37 +637,37 @@ jobs:
|
||||
comment += metadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Only post comment if there are changes
|
||||
if (hasChanges) {
|
||||
// Add branch state information only if there were conflicts
|
||||
const branchState = process.env.BRANCH_STATE || 'unknown';
|
||||
let branchStateNote = '';
|
||||
|
||||
|
||||
if (branchState === 'conflicts') {
|
||||
branchStateNote = '\n\n⚠️ **Note**: Could not merge with `main` due to conflicts. This comparison shows changes between the current branch and `main` as separate states.\n';
|
||||
}
|
||||
// Check if there are any breaking changes detected
|
||||
let hasBreakingChanges = false;
|
||||
let breakingChangeNote = '';
|
||||
|
||||
|
||||
// Check for breaking changes in any of the diff files
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
|
||||
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
|
||||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
|
||||
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
|
||||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also check GraphQL changes for breaking changes indicators
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
@@ -675,18 +675,18 @@ jobs:
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check PR title for "breaking"
|
||||
const prTitle = ${{ toJSON(github.event.pull_request.title) }};
|
||||
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
|
||||
|
||||
|
||||
if (hasBreakingChanges) {
|
||||
if (titleContainsBreaking) {
|
||||
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
|
||||
@@ -702,20 +702,20 @@ jobs:
|
||||
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const commentBody = COMMENT_MARKER + '\n' + comment + branchStateNote + '\n⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
|
||||
|
||||
|
||||
// Get all comments to find existing API changes comment
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
|
||||
// Find our existing comment
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
@@ -737,17 +737,17 @@ jobs:
|
||||
}
|
||||
} else {
|
||||
console.log('No API changes detected - skipping PR comment');
|
||||
|
||||
|
||||
// Check if there's an existing comment to remove
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
@@ -780,7 +780,7 @@ jobs:
|
||||
/tmp/main-server.log
|
||||
/tmp/current-server.log
|
||||
*-api.json
|
||||
*-schema-introspection.json
|
||||
*-schema-introspection.json
|
||||
*-diff.md
|
||||
*-diff.json
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
name: CI CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/twenty-cli/**
|
||||
packages/twenty-server/**
|
||||
cli-test:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test, build]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/[email protected]
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:cli
|
||||
tasks: ${{ matrix.task }}
|
||||
cli-e2e-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: [changed-files-check, cli-test]
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
NODE_ENV: test
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Server / Append billing config to .env.test
|
||||
working-directory: packages/twenty-server
|
||||
run: |
|
||||
echo "" >> .env.test
|
||||
echo "IS_BILLING_ENABLED=true" >> .env.test
|
||||
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
|
||||
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Server / Create Test DB
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
- name: CLI / Run E2E Tests
|
||||
run: npx nx test:e2e twenty-cli
|
||||
ci-cli-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, cli-test, cli-e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -1,57 +0,0 @@
|
||||
name: CI Create App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/create-twenty-app/**
|
||||
create-app-test:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/[email protected]
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build create-twenty-app
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:create-app
|
||||
tasks: ${{ matrix.task }}
|
||||
ci-create-app-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, create-app-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Docs / Lint English MDX files
|
||||
run: npx eslint "packages/twenty-docs/{developers,user-guide,twenty-ui,getting-started,snippets}/**/*.mdx" --max-warnings 0
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
name: CI E2E Playwright Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/**
|
||||
playwright.config.ts
|
||||
.github/workflows/ci-e2e.yaml
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true' && ( github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# https://github.com/actions/runner-images/issues/70#issuecomment-589562148
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: "true"
|
||||
SPILO_PROVIDER: "local"
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Check system resources
|
||||
run: |
|
||||
echo "Available memory:"
|
||||
free -h
|
||||
echo "Available disk space:"
|
||||
df -h
|
||||
echo "CPU info:"
|
||||
lscpu
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx nx setup twenty-e2e-testing
|
||||
|
||||
- name: Setup environment files
|
||||
run: |
|
||||
cp packages/twenty-front/.env.example packages/twenty-front/.env
|
||||
npx nx reset:env twenty-server
|
||||
|
||||
- name: Build frontend
|
||||
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
|
||||
|
||||
- name: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Start frontend
|
||||
run: |
|
||||
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
|
||||
echo "Waiting for frontend to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
|
||||
|
||||
- name: Start worker
|
||||
run: |
|
||||
npx nx run twenty-server:worker &
|
||||
echo "Worker started"
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npx nx test twenty-e2e-testing
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: packages/twenty-e2e-testing/run_results/
|
||||
retention-days: 30
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: packages/twenty-e2e-testing/playwright-report/
|
||||
retention-days: 30
|
||||
ci-e2e-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Build twenty-emails
|
||||
run: npx nx build twenty-emails
|
||||
- name: Run email tests
|
||||
@@ -40,17 +40,17 @@ jobs:
|
||||
# Start the email server in the background
|
||||
npx nx run twenty-emails:start &
|
||||
SERVER_PID=$!
|
||||
|
||||
|
||||
# Wait for server to start
|
||||
sleep 20
|
||||
|
||||
|
||||
# Check if server is running
|
||||
if ! curl -s http://localhost:4001/preview/test.email > /dev/null; then
|
||||
echo "Email server failed to start"
|
||||
kill $SERVER_PID
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Kill the server
|
||||
kill $SERVER_PID
|
||||
ci-emails-status-check:
|
||||
@@ -61,4 +61,4 @@ jobs:
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
run: exit 1
|
||||
+21
-174
@@ -1,9 +1,11 @@
|
||||
name: CI Front and E2E
|
||||
name: CI Front
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
merge_group:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,9 +15,8 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v3-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
@@ -27,13 +28,6 @@ jobs:
|
||||
packages/twenty-front/**
|
||||
packages/twenty-ui/**
|
||||
packages/twenty-shared/**
|
||||
changed-files-check-e2e:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/**
|
||||
playwright.config.ts
|
||||
.github/workflows/ci-front.yaml
|
||||
front-sb-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
@@ -51,17 +45,15 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Diagnostic disk space issue
|
||||
run: df -h
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Front / Clean storybook-static
|
||||
run: rm -rf packages/twenty-front/storybook-static
|
||||
- name: Front / Build storybook
|
||||
run: npx nx storybook:build twenty-front
|
||||
- name: Save storybook build cache
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
|
||||
front-sb-test:
|
||||
@@ -82,11 +74,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Install Playwright
|
||||
run: cd packages/twenty-front && npx playwright install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
- name: Front / Write .env
|
||||
@@ -115,7 +107,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
|
||||
@@ -129,7 +121,7 @@ jobs:
|
||||
run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
|
||||
front-chromatic-deployment:
|
||||
timeout-minutes: 30
|
||||
if: false
|
||||
if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push'
|
||||
needs: front-sb-build
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
@@ -140,11 +132,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
|
||||
- name: Front / Write .env
|
||||
run: |
|
||||
cd packages/twenty-front
|
||||
@@ -173,162 +165,26 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore ${{ matrix.task }} cache
|
||||
id: restore-task-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.TASK_CACHE_KEY }}
|
||||
- name: Reset .env
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: reset:env
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
- name: Save ${{ matrix.task }} cache
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-task-cache.outputs.cache-primary-key }}
|
||||
front-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/[email protected]
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Build frontend
|
||||
run: npx nx build twenty-front
|
||||
- name: Upload frontend build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: packages/twenty-front/build
|
||||
retention-days: 1
|
||||
e2e-test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check-e2e, front-build]
|
||||
if: |
|
||||
always() &&
|
||||
needs.changed-files-check-e2e.outputs.any_changed == 'true' &&
|
||||
(needs.front-build.result == 'success' || needs.front-build.result == 'skipped') &&
|
||||
(github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: "true"
|
||||
SPILO_PROVIDER: "local"
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Check system resources
|
||||
run: |
|
||||
echo "Available memory:"
|
||||
free -h
|
||||
echo "Available disk space:"
|
||||
df -h
|
||||
echo "CPU info:"
|
||||
lscpu
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx nx setup twenty-e2e-testing
|
||||
|
||||
- name: Setup environment files
|
||||
run: |
|
||||
cp packages/twenty-front/.env.example packages/twenty-front/.env
|
||||
npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Download frontend build artifact
|
||||
if: needs.front-build.result == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: packages/twenty-front/build
|
||||
|
||||
- name: Build frontend (if not available from front-build)
|
||||
if: needs.front-build.result == 'skipped'
|
||||
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
|
||||
|
||||
- name: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Start frontend
|
||||
run: |
|
||||
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
|
||||
echo "Waiting for frontend to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
|
||||
|
||||
- name: Start worker
|
||||
run: |
|
||||
npx nx run twenty-server:worker &
|
||||
echo "Worker started"
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npx nx test twenty-e2e-testing
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: packages/twenty-e2e-testing/run_results/
|
||||
retention-days: 30
|
||||
|
||||
ci-front-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
@@ -337,7 +193,7 @@ jobs:
|
||||
[
|
||||
changed-files-check,
|
||||
front-task,
|
||||
front-build,
|
||||
front-chromatic-deployment,
|
||||
merge-reports-and-check-coverage,
|
||||
front-sb-test,
|
||||
front-sb-build,
|
||||
@@ -346,12 +202,3 @@ jobs:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
ci-e2e-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check-e2e, e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
@@ -56,4 +56,4 @@ jobs:
|
||||
title: Release v${{ steps.sanitize.outputs.version }}
|
||||
labels: |
|
||||
release
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
@@ -1,102 +0,0 @@
|
||||
name: CI SDK
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/twenty-sdk/**
|
||||
sdk-test:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/[email protected]
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build twenty-sdk
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:sdk
|
||||
tasks: ${{ matrix.task }}
|
||||
sdk-e2e-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: [changed-files-check, sdk-test]
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
NODE_ENV: test
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Server / Append billing config to .env.test
|
||||
working-directory: packages/twenty-server
|
||||
run: |
|
||||
echo "" >> .env.test
|
||||
echo "IS_BILLING_ENABLED=true" >> .env.test
|
||||
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
|
||||
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Server / Create Test DB
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
- name: SDK / Run E2E Tests
|
||||
run: npx nx test:e2e twenty-sdk
|
||||
ci-sdk-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, sdk-test, sdk-e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -1,9 +1,11 @@
|
||||
name: CI Server
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
merge_group:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -57,16 +59,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
id: restore-server-setup-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
- name: Server / Run lint & typecheck
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: lint,typecheck
|
||||
@@ -138,7 +140,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
- name: Save server setup
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
|
||||
server-test:
|
||||
@@ -151,13 +153,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Run Tests
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: test
|
||||
@@ -169,7 +171,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
@@ -207,14 +209,14 @@ jobs:
|
||||
ANALYTICS_ENABLED: true
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
SHARD_COUNTER: 8
|
||||
SHARD_COUNTER: 5
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Update .env.test for integrations tests
|
||||
run: |
|
||||
echo "" >> .env.test
|
||||
@@ -224,7 +226,7 @@ jobs:
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Restore server setup
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Build
|
||||
@@ -241,7 +243,7 @@ jobs:
|
||||
- name: Run ClickHouse seeds
|
||||
run: npx nx clickhouse:seed twenty-server
|
||||
- name: Server / Run Integration Tests
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: 'test:integration'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
name: CI Shared
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -36,9 +38,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
name: CI Docker Compose
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
name: 'Test Docker Compose'
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -30,12 +30,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Utils / Run Danger.js
|
||||
run: cd packages/twenty-utils && npx nx danger:ci
|
||||
env:
|
||||
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
|
||||
|
||||
|
||||
congratulate:
|
||||
timeout-minutes: 3
|
||||
runs-on: ubuntu-latest
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run congratulate-dangerfile.js
|
||||
run: cd packages/twenty-utils && npx nx danger:congratulate
|
||||
env:
|
||||
|
||||
@@ -4,7 +4,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -45,7 +47,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Server / Create DB
|
||||
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
|
||||
@@ -24,7 +24,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/twenty-docs/**'
|
||||
- 'crowdin-docs.yml'
|
||||
- 'crowdin.yml'
|
||||
- '.github/workflows/docs-i18n-pull.yaml'
|
||||
|
||||
concurrency:
|
||||
@@ -42,14 +42,31 @@ jobs:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Setup i18n-docs branch
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Setup i18n branch
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
git fetch origin i18n-docs || true
|
||||
git checkout -B i18n-docs origin/i18n-docs || git checkout -b i18n-docs
|
||||
git fetch origin i18n || true
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
@@ -62,35 +79,26 @@ jobs:
|
||||
git add .
|
||||
git stash || true
|
||||
|
||||
# Install Crowdin CLI for downloading translations
|
||||
- name: Install Crowdin CLI
|
||||
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
run: npm install -g @crowdin/cli
|
||||
|
||||
# Pull docs translations from Crowdin one language at a time
|
||||
# This avoids build timeout issues when processing all languages at once
|
||||
- name: Pull translated docs from Crowdin
|
||||
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
run: |
|
||||
# Languages supported by Mintlify (see packages/twenty-docs/src/shared/supported-languages.ts)
|
||||
LANGUAGES="fr ar cs de es it ja ko pt ro ru tr zh-CN"
|
||||
|
||||
for lang in $LANGUAGES; do
|
||||
echo "=== Pulling translations for $lang ==="
|
||||
crowdin download \
|
||||
--config crowdin-docs.yml \
|
||||
--token "$CROWDIN_PERSONAL_TOKEN" \
|
||||
--base-url "https://twenty.api.crowdin.com" \
|
||||
--language "$lang" \
|
||||
--skip-untranslated-strings=false \
|
||||
--skip-untranslated-files=false \
|
||||
--export-only-approved=false \
|
||||
--verbose || echo "Warning: Failed to pull $lang, continuing with other languages..."
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "=== Download complete ==="
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: 'packages/twenty-docs/**/*.mdx'
|
||||
translation: 'packages/twenty-docs/l/%two_letters_code%/**/%original_file_name%'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
skip_untranslated_files: true
|
||||
push_translations: false
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Fix file permissions
|
||||
@@ -116,9 +124,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: sync docs artifacts"
|
||||
git push origin "HEAD:$HEAD_REF"
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
|
||||
- name: Check for changes and commit
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -134,13 +140,13 @@ jobs:
|
||||
|
||||
- name: Push changes
|
||||
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n-docs
|
||||
run: git push origin HEAD:i18n
|
||||
|
||||
- name: Create pull request
|
||||
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
if git diff --name-only origin/main..HEAD | grep -q .; then
|
||||
gh pr create -B main -H i18n-docs --title 'i18n - docs translations' --body 'Created by Github action' || true
|
||||
gh pr create -B main -H i18n --title 'i18n - docs translations' --body 'Created by Github action' || true
|
||||
else
|
||||
echo "No file differences between branches, skipping PR creation"
|
||||
fi
|
||||
|
||||
@@ -7,12 +7,11 @@ on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ['main']
|
||||
branches: ['main', 'docs-localized-navigation']
|
||||
paths:
|
||||
- 'packages/twenty-docs/**/*.mdx'
|
||||
- '!packages/twenty-docs/l/**'
|
||||
- 'packages/twenty-docs/navigation/navigation.template.json'
|
||||
- 'crowdin-docs.yml'
|
||||
- '!packages/twenty-docs/fr/**'
|
||||
- 'crowdin.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@@ -29,8 +28,28 @@ jobs:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Generate navigation template for Crowdin
|
||||
run: yarn docs:generate-navigation-template
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Generate navigation template for Crowdin
|
||||
run: yarn docs:generate-navigation-template
|
||||
@@ -41,11 +60,9 @@ jobs:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
localization_branch_name: i18n-docs
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: 'crowdin-docs.yml'
|
||||
env:
|
||||
# Docs translations project
|
||||
CROWDIN_PROJECT_ID: '2'
|
||||
CROWDIN_PROJECT_ID: 1
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
@@ -85,14 +85,12 @@ jobs:
|
||||
push_sources: false
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
push_translations: false
|
||||
push_translations: true
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
config: 'crowdin-app.yml'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# App translations project
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -101,12 +99,6 @@ jobs:
|
||||
- name: Fix file permissions
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
# Fix encoding issues (escaped Unicode like \u62db -> 招) and push fixes back to Crowdin
|
||||
- name: Fix translation encoding and sync to Crowdin
|
||||
run: npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Compile translations
|
||||
id: compile_translations
|
||||
# Because we have set English as a fallback locale, this condition does not work anymore
|
||||
@@ -116,6 +108,8 @@ jobs:
|
||||
npx nx run twenty-emails:lingui:compile
|
||||
npx nx run twenty-front:lingui:compile
|
||||
git status
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email '[email protected]'
|
||||
git add .
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile translations"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
@@ -87,10 +87,11 @@ jobs:
|
||||
download_translations: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: 'crowdin-app.yml'
|
||||
env:
|
||||
# App translations project
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: 1
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: Create a pull request
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# Weekly translation QA report using Crowdin's native QA checks
|
||||
|
||||
name: 'Weekly Translation QA Report'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Every Monday at 9am UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qa_report:
|
||||
name: Generate QA Report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Generate QA report from Crowdin
|
||||
id: generate_report
|
||||
run: |
|
||||
npx ts-node packages/twenty-utils/translation-qa-report.ts || true
|
||||
if [ -f TRANSLATION_QA_REPORT.md ]; then
|
||||
echo "report_generated=true" >> $GITHUB_OUTPUT
|
||||
# Count critical issues (exclude spellcheck)
|
||||
CRITICAL=$(grep -oP '⚠️\s+\K\d+' TRANSLATION_QA_REPORT.md 2>/dev/null || echo "0")
|
||||
echo "critical_issues=$CRITICAL" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "report_generated=false" >> $GITHUB_OUTPUT
|
||||
echo "critical_issues=0" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Create QA branch and commit report
|
||||
if: steps.generate_report.outputs.report_generated == 'true'
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email '[email protected]'
|
||||
|
||||
BRANCH_NAME="i18n-qa-report-$(date +%Y-%m-%d)"
|
||||
git checkout -B $BRANCH_NAME
|
||||
|
||||
git add TRANSLATION_QA_REPORT.md
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "docs: weekly translation QA report"
|
||||
git push origin HEAD:$BRANCH_NAME --force
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
else
|
||||
echo "No changes to commit"
|
||||
echo "BRANCH_NAME=" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.generate_report.outputs.report_generated == 'true' && env.BRANCH_NAME != ''
|
||||
run: |
|
||||
CRITICAL="${{ steps.generate_report.outputs.critical_issues }}"
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
## Weekly Translation QA Report
|
||||
|
||||
**Critical issues (excluding spellcheck): $CRITICAL**
|
||||
|
||||
📊 **View in Crowdin**: https://twenty.crowdin.com/u/projects/1/all?filter=qa-issue
|
||||
|
||||
### For AI-Assisted Fixing
|
||||
|
||||
Open this PR in Cursor and say:
|
||||
|
||||
> "Fix the translation QA issues using the Crowdin API"
|
||||
|
||||
The AI can help fix:
|
||||
- ✅ Variables mismatch (missing/wrong placeholders)
|
||||
- ✅ Escaped Unicode sequences
|
||||
- ⚠️ Tags mismatch
|
||||
- ⚠️ Empty translations
|
||||
|
||||
### Available Scripts
|
||||
|
||||
\`\`\`bash
|
||||
# View QA report
|
||||
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/translation-qa-report.ts
|
||||
|
||||
# Fix encoding issues automatically
|
||||
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
*Close without merging after issues are addressed*
|
||||
EOF
|
||||
)
|
||||
|
||||
EXISTING_PR=$(gh pr list --head $BRANCH_NAME --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
gh pr edit $EXISTING_PR --body "$BODY"
|
||||
else
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head $BRANCH_NAME \
|
||||
--title "i18n: Translation QA Report ($CRITICAL critical issues)" \
|
||||
--body "$BODY" || true
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -48,4 +48,3 @@ dump.rdb
|
||||
|
||||
mcp.json
|
||||
/.junie/
|
||||
TRANSLATION_QA_REPORT.md
|
||||
|
||||
Vendored
+1
-1
@@ -67,7 +67,7 @@
|
||||
"--config",
|
||||
"./jest-integration.config.ts",
|
||||
"${relativeFile}",
|
||||
"--silent=false",
|
||||
"--silent=false"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/packages/twenty-server",
|
||||
"console": "integratedTerminal",
|
||||
|
||||
Vendored
-1
@@ -31,7 +31,6 @@
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"javascript.format.enable": false,
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.format.enable": false,
|
||||
"cSpell.enableFiletypes": [
|
||||
"!javascript",
|
||||
|
||||
@@ -36,15 +36,10 @@ When testing the UI end to end, click on "Continue with Email" and use the prefi
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Linting (diff with main - fastest)
|
||||
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
|
||||
npx nx lint:diff-with-main twenty-server # Lint only files changed vs main
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix files changed vs main
|
||||
|
||||
# Linting (full project)
|
||||
npx nx lint twenty-front # Lint all files in frontend
|
||||
npx nx lint twenty-server # Lint all files in backend
|
||||
npx nx lint twenty-front --fix # Auto-fix all linting issues
|
||||
# Linting
|
||||
npx nx lint twenty-front # Frontend linting
|
||||
npx nx lint twenty-server # Backend linting
|
||||
npx nx lint twenty-front --fix # Auto-fix linting issues
|
||||
|
||||
# Type checking
|
||||
npx nx typecheck twenty-front
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#
|
||||
# Crowdin CLI configuration for App translations (twenty-front, twenty-server, twenty-emails)
|
||||
# Project ID: 1
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"preserve_hierarchy": true
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Documentation translations
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"project_id": 2
|
||||
"preserve_hierarchy": true
|
||||
"base_url": "https://twenty.api.crowdin.com"
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# MDX documentation files - user-guide
|
||||
# Using md type to preserve JSX component structure
|
||||
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
|
||||
#
|
||||
"source": "packages/twenty-docs/user-guide/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - developers
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/developers/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - twenty-ui
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# Navigation labels template - translated into per-locale navigation.json
|
||||
#
|
||||
"source": "packages/twenty-docs/navigation/navigation.template.json",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
|
||||
}
|
||||
]
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# Basic Crowdin CLI configuration
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
# See https://support.crowdin.com/developer/configuration-file/ for all available options
|
||||
#
|
||||
|
||||
#
|
||||
# Defines whether to preserve the original directory structure in the Crowdin project
|
||||
# Recommended to set to true
|
||||
#
|
||||
"preserve_hierarchy": true
|
||||
|
||||
#
|
||||
# Files configuration.
|
||||
# See https://support.crowdin.com/developer/configuration-file/ for all available options
|
||||
#
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter
|
||||
# e.g. "/resources/en/*.json"
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files filter
|
||||
# e.g. "/resources/%two_letters_code%/%original_file_name%"
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - user-guide
|
||||
# Using md type to preserve JSX component structure
|
||||
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
|
||||
#
|
||||
"source": "packages/twenty-docs/user-guide/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - developers
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/developers/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - twenty-ui
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# Navigation labels template - translated into per-locale navigation.json
|
||||
#
|
||||
"source": "packages/twenty-docs/navigation/navigation.template.json",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
|
||||
}
|
||||
]
|
||||
+6
-2
@@ -75,6 +75,10 @@ export default [
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -137,10 +141,10 @@ export default [
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowInterfaces: 'with-single-extends',
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
|
||||
+6
-496
@@ -128,375 +128,6 @@ export default [
|
||||
additionalHooks: 'useRecoilCallback',
|
||||
},
|
||||
],
|
||||
// Lingui - detect untranslated strings
|
||||
'lingui/no-unlocalized-strings': [
|
||||
'error',
|
||||
{
|
||||
ignore: [
|
||||
// Ignore strings which are a single "word" (no spaces) and don't start with uppercase
|
||||
'^(?![A-Z])\\S+$',
|
||||
// Ignore UPPERCASE literals (constants, env vars)
|
||||
'^[A-Z0-9_-]+$',
|
||||
// Ignore strings that look like code/technical (contain special chars)
|
||||
'^[\\s]*$', // whitespace only
|
||||
'.*[{}/<>].*', // contains code-like characters
|
||||
'^\\d+(\\.\\d+)?(px|rem|em|%|vh|vw|s|ms)?$', // CSS units
|
||||
'^[\\d.]+(px|rem|em|%|vh|vw|fr|s|ms)?(\\s+[\\d.]+(px|rem|em|%|vh|vw|fr|s|ms)?)*$', // CSS values like "200px 1fr 20px"
|
||||
'^#[0-9a-fA-F]{3,8}$', // hex colors
|
||||
'^rgba?\\(.*\\)$', // rgb/rgba colors
|
||||
'^(auto|none|inherit|initial|unset|flex|grid|block|inline|inline-block|relative|absolute|fixed|sticky)$', // CSS keywords
|
||||
'^color:.*$', // CSS color declarations
|
||||
'^font-.*$', // CSS font declarations
|
||||
'^\\d+$', // numbers only
|
||||
'^https?:\\/\\/.*', // URLs
|
||||
'^@.*', // @ mentions or decorators
|
||||
'^\\/.*', // paths starting with /
|
||||
'^[HhMmSsYyDdAaPp:.,\\s-]+$', // date format patterns (HH:mm, yyyy-MM-dd, etc.)
|
||||
'^Arrow(Up|Down|Left|Right)$', // keyboard keys
|
||||
'^(Enter|Escape|Tab|Space|Backspace|Delete)$', // keyboard keys
|
||||
'^Text$', // clipboard data type
|
||||
'^(allow-|sandbox)', // iframe sandbox values
|
||||
'^Id$', // technical identifier suffix (e.g., fieldNameId)
|
||||
'^(string|number|boolean|void|any|unknown|never|object)$', // TypeScript type keywords
|
||||
'^(Dark|Light)$', // color schemes
|
||||
'^translate\\(.*\\)$', // CSS transform strings
|
||||
'^svg .*$', // CSS selectors
|
||||
'^Icon[A-Z]\\w*$', // Icon names like IconDefault, IconTable, IconSettings
|
||||
'^\\w*Icon$', // Icon names that end with Icon like FieldIcon
|
||||
'^%c.*$', // Console format strings
|
||||
// Common item IDs for selectable lists
|
||||
'^(Group|CalendarView|CalendarDateField|Compact view)$',
|
||||
'^(Layout|Visibility|Fields|Delete view|Copy link to view|Create custom view)$',
|
||||
'^(GroupBy|Sort|HideEmptyGroups|HiddenGroups)$',
|
||||
// HTTP headers and auth (technical, not user-facing)
|
||||
'^Authorization$',
|
||||
'^Bearer .*',
|
||||
// Allow object keys that are technical identifiers
|
||||
'^(topLeft|topRight|bottomLeft|bottomRight)$',
|
||||
// Color schemes and CSS media queries
|
||||
'^System$',
|
||||
'^\\(prefers-color-scheme:',
|
||||
// GraphQL query names (used in refetchQueries)
|
||||
'^Get[A-Z]\\w*$',
|
||||
// React Context names (technical identifiers)
|
||||
'.*Context$',
|
||||
// SVG paths (geometric coordinates, not translatable)
|
||||
'^M[0-9 LML]+$',
|
||||
'^[ML][0-9 ]+$',
|
||||
// Database ordering values (technical, backend API)
|
||||
'^(Asc|Desc)Nulls(First|Last)$',
|
||||
// Calendar response status values (backend enum values, not user-facing)
|
||||
'^(Yes|No|Maybe)$',
|
||||
// Email validation error prefixes (combined with dynamic content)
|
||||
'^Invalid email(s)?:',
|
||||
// GraphQL type construction patterns
|
||||
'.*FilterInput$',
|
||||
'.*OrderByInput.*',
|
||||
'^\\$filter.*',
|
||||
'^\\$orderBy.*',
|
||||
'^\\$after.*',
|
||||
'^\\$before.*',
|
||||
'^\\$first.*',
|
||||
'^\\$last.*',
|
||||
// Logger names (technical identifiers)
|
||||
'^Twenty(-\\w+)?$',
|
||||
// Cookie names and cookie string patterns
|
||||
'^twenty_session_id$',
|
||||
'^; domain=',
|
||||
// Context names for createRequiredContext
|
||||
'^[A-Z][a-zA-Z]+$',
|
||||
// JSON-like filter patterns
|
||||
'^%"type":',
|
||||
'^%"objectNameSingular":',
|
||||
],
|
||||
ignoreNames: [
|
||||
// HTML/React attributes that shouldn't be translated
|
||||
{ regex: { pattern: 'className', flags: 'i' } },
|
||||
{ regex: { pattern: 'styleName', flags: 'i' } },
|
||||
{ regex: { pattern: 'testId', flags: 'i' } },
|
||||
'data-testid',
|
||||
'dataTestId',
|
||||
'src',
|
||||
'srcSet',
|
||||
'href',
|
||||
'target',
|
||||
'rel',
|
||||
'type',
|
||||
'id',
|
||||
'key',
|
||||
'name',
|
||||
'htmlFor',
|
||||
'width',
|
||||
'height',
|
||||
'fill',
|
||||
'stroke',
|
||||
'viewBox',
|
||||
'clipPath',
|
||||
'd', // SVG path
|
||||
'transform',
|
||||
'displayName',
|
||||
'defaultValue',
|
||||
'to', // router links
|
||||
'path',
|
||||
'pathname',
|
||||
'hash',
|
||||
'componentInstanceId',
|
||||
'hotkeyScope',
|
||||
'dropdownId',
|
||||
'recoilScopeId',
|
||||
'modalId',
|
||||
'dialogId',
|
||||
'itemId',
|
||||
'selectableItemIdArray',
|
||||
'listenerId',
|
||||
'focusId',
|
||||
'color', // color prop values
|
||||
'variant', // component variants
|
||||
'size', // size prop values
|
||||
'position', // position values
|
||||
'align', // alignment values
|
||||
'justify', // justification values
|
||||
'direction', // direction values
|
||||
'orientation', // orientation values
|
||||
'status', // status values
|
||||
'state', // state values
|
||||
'mode', // mode values
|
||||
'accent', // accent values
|
||||
|
||||
// CSS-related props
|
||||
'gridAutoColumns',
|
||||
'gridAutoRows',
|
||||
'gridTemplateColumns',
|
||||
'gridTemplateRows',
|
||||
'gridColumn',
|
||||
'gridRow',
|
||||
'gap',
|
||||
'margin',
|
||||
'padding',
|
||||
'border',
|
||||
'borderRadius',
|
||||
'boxShadow',
|
||||
'flex',
|
||||
'flexDirection',
|
||||
'flexWrap',
|
||||
'justifyContent',
|
||||
'alignItems',
|
||||
'alignContent',
|
||||
'overflow',
|
||||
'display',
|
||||
'cursor',
|
||||
'zIndex',
|
||||
'opacity',
|
||||
'fontWeight',
|
||||
'fontSize',
|
||||
'lineHeight',
|
||||
'textAlign',
|
||||
'textDecoration',
|
||||
'whiteSpace',
|
||||
'wordBreak',
|
||||
'objectFit',
|
||||
'backgroundSize',
|
||||
'backgroundPosition',
|
||||
'minWidth',
|
||||
'maxWidth',
|
||||
'minHeight',
|
||||
'maxHeight',
|
||||
'mobileGridAutoColumns',
|
||||
'tabletGridAutoColumns',
|
||||
|
||||
// Styled components
|
||||
'css',
|
||||
'theme',
|
||||
'animation',
|
||||
'transition',
|
||||
|
||||
// GraphQL
|
||||
'query',
|
||||
'mutation',
|
||||
'subscription',
|
||||
'fragment',
|
||||
'operationName',
|
||||
'variables',
|
||||
'__typename',
|
||||
|
||||
// Technical identifiers
|
||||
'fieldName',
|
||||
'columnName',
|
||||
'objectNameSingular',
|
||||
'objectNamePlural',
|
||||
'metadataId',
|
||||
'nameSingular',
|
||||
'namePlural',
|
||||
|
||||
// Event types
|
||||
'eventName',
|
||||
'event',
|
||||
'action',
|
||||
'actionType',
|
||||
|
||||
// Icon names
|
||||
'iconName',
|
||||
{ regex: { pattern: '^Icon[A-Z]' } },
|
||||
|
||||
// UPPER_CASE names (constants)
|
||||
{ regex: { pattern: '^[A-Z][A-Z0-9_]*$' } },
|
||||
|
||||
// Sort direction values (backend API)
|
||||
'orderBy',
|
||||
{ regex: { pattern: '^(Asc|Desc)(NullsFirst|NullsLast)?$' } },
|
||||
|
||||
// HTTP headers (technical, not user-facing)
|
||||
'Authorization',
|
||||
],
|
||||
ignoreFunctions: [
|
||||
// Console and logging
|
||||
'console.*',
|
||||
'*.log',
|
||||
'*.warn',
|
||||
'*.error',
|
||||
'*.debug',
|
||||
'*.info',
|
||||
'*.trace',
|
||||
'logDebug',
|
||||
'formatTitle',
|
||||
|
||||
// Error handling (technical messages, not user-facing)
|
||||
'Error',
|
||||
'TypeError',
|
||||
'RangeError',
|
||||
'SyntaxError',
|
||||
'throw',
|
||||
'assertUnreachable',
|
||||
'CustomError',
|
||||
'parseInitialBlocknote',
|
||||
|
||||
// Testing
|
||||
'describe',
|
||||
'it',
|
||||
'test',
|
||||
'expect',
|
||||
'jest.*',
|
||||
'*.toBe',
|
||||
'*.toEqual',
|
||||
'*.toContain',
|
||||
'*.toMatch',
|
||||
'*.toThrow',
|
||||
|
||||
// React/Libraries internals
|
||||
'require',
|
||||
'import',
|
||||
'styled',
|
||||
'styled.*',
|
||||
'css',
|
||||
'keyframes',
|
||||
'createGlobalStyle',
|
||||
|
||||
// Router
|
||||
'useNavigate',
|
||||
'navigate',
|
||||
'useLocation',
|
||||
'useParams',
|
||||
|
||||
// Date formatting (patterns are not translatable)
|
||||
'format',
|
||||
'formatDate',
|
||||
'formatDateTime',
|
||||
'formatTime',
|
||||
'parseISO',
|
||||
'parse',
|
||||
|
||||
// Navigation
|
||||
'useNavigationSection',
|
||||
|
||||
// Recoil
|
||||
'atom',
|
||||
'atomFamily',
|
||||
'selector',
|
||||
'selectorFamily',
|
||||
'useSetRecoilState',
|
||||
'useRecoilState',
|
||||
'useRecoilValue',
|
||||
|
||||
// GraphQL operations
|
||||
'gql',
|
||||
'useQuery',
|
||||
'useMutation',
|
||||
'useLazyQuery',
|
||||
'useSubscription',
|
||||
|
||||
// Type checking and validation
|
||||
'*.includes',
|
||||
'*.indexOf',
|
||||
'*.startsWith',
|
||||
'*.endsWith',
|
||||
'*.split',
|
||||
'*.join',
|
||||
'*.match',
|
||||
'*.replace',
|
||||
'*.test',
|
||||
'Object.keys',
|
||||
'Object.values',
|
||||
'Object.entries',
|
||||
'Array.isArray',
|
||||
|
||||
// DOM operations
|
||||
'*.getElementById',
|
||||
'*.getElementsByClassName',
|
||||
'*.querySelector',
|
||||
'*.querySelectorAll',
|
||||
'*.getAttribute',
|
||||
'*.setAttribute',
|
||||
'*.addEventListener',
|
||||
'*.removeEventListener',
|
||||
'*.dispatchEvent',
|
||||
'*.createElement',
|
||||
|
||||
// Storage
|
||||
'localStorage.*',
|
||||
'sessionStorage.*',
|
||||
'searchParams.*',
|
||||
'*.get',
|
||||
'*.set',
|
||||
'*.has',
|
||||
'*.delete',
|
||||
|
||||
// Misc utilities
|
||||
'cva',
|
||||
'cn',
|
||||
'clsx',
|
||||
'classNames',
|
||||
'track',
|
||||
'*.postMessage',
|
||||
'*.dispatch',
|
||||
'*.commit',
|
||||
|
||||
// Event handlers (typically receive enum values, not user-facing text)
|
||||
'onChange',
|
||||
'onClick',
|
||||
'onSelect',
|
||||
'onSubmit',
|
||||
'onFocus',
|
||||
'onBlur',
|
||||
'onKeyDown',
|
||||
'onKeyUp',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave',
|
||||
|
||||
// Logging functions (technical messages, not user-facing)
|
||||
'logError',
|
||||
'logDebug',
|
||||
'logInfo',
|
||||
'logWarn',
|
||||
'loggerLink',
|
||||
|
||||
// Context creation (technical names)
|
||||
'createRequiredContext',
|
||||
|
||||
// GraphQL refetch queries (technical identifiers)
|
||||
'refetchQueries',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -521,10 +152,6 @@ export default [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['../*'],
|
||||
message: 'Relative parent imports are not allowed. Use @/ alias instead.',
|
||||
},
|
||||
{
|
||||
group: ['@tabler/icons-react'],
|
||||
message: 'Please import icons from `twenty-ui`',
|
||||
@@ -548,7 +175,7 @@ export default [
|
||||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports'
|
||||
},
|
||||
@@ -556,10 +183,10 @@ export default [
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowInterfaces: 'with-single-extends',
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
@@ -581,119 +208,14 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
// Storybook files and story-related files
|
||||
// Storybook files
|
||||
{
|
||||
files: [
|
||||
'**/*.stories.ts',
|
||||
'**/*.stories.tsx',
|
||||
'**/*.stories.js',
|
||||
'**/*.stories.jsx',
|
||||
'**/__stories__/**/*',
|
||||
],
|
||||
files: ['*.stories.@(ts|tsx|js|jsx)'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Debug files - development only, not user-facing
|
||||
{
|
||||
files: [
|
||||
'**/Debug*.tsx',
|
||||
'**/*Debug*.tsx',
|
||||
'**/*DebugDisplay*.tsx',
|
||||
'**/*DebugHelper*.tsx',
|
||||
'**/*DebugObserver*.tsx',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Testing utilities and mock data - not user-facing
|
||||
{
|
||||
files: [
|
||||
'**/testing/**/*.tsx',
|
||||
'**/testing/**/*.ts',
|
||||
'**/__mocks__/**/*',
|
||||
'**/*mock*.ts',
|
||||
'**/*Mock*.ts',
|
||||
'**/perf/**/*',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Constants files - technical values, not user-facing
|
||||
{
|
||||
files: [
|
||||
'**/constants/**/*.ts',
|
||||
'**/*.constants.ts',
|
||||
'**/validation-schemas/**/*.ts',
|
||||
'**/*Schema.ts',
|
||||
'**/*-schema.ts',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Layout configuration files - titles are translated at consumption time
|
||||
{
|
||||
files: ['**/layouts/**/*.ts'],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Service files - contain technical strings (logger names, HTTP headers, etc.)
|
||||
{
|
||||
files: ['**/services/**/*.ts'],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// State files - contain technical default values
|
||||
{
|
||||
files: ['**/states/**/*.ts'],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Utility files - technical/developer-facing
|
||||
{
|
||||
files: [
|
||||
'**/utils/**/*.ts',
|
||||
'**/*Utils.ts',
|
||||
'**/*-utils.ts',
|
||||
'**/*Util.ts',
|
||||
'**/*-util.ts',
|
||||
'**/errors/**/*.ts',
|
||||
'**/*Error.ts',
|
||||
'**/*-error.ts',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Config and setup files - not user-facing
|
||||
{
|
||||
files: [
|
||||
'**/*.config.ts',
|
||||
'**/*.config.js',
|
||||
'**/vite.config.ts',
|
||||
'**/.storybook/**/*',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// JavaScript specific configuration
|
||||
{
|
||||
files: ['*.{js,jsx}'],
|
||||
@@ -728,18 +250,7 @@ export default [
|
||||
// Test files
|
||||
{
|
||||
files: [
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.test.js',
|
||||
'**/*.test.jsx',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
'**/*.spec.js',
|
||||
'**/*.spec.jsx',
|
||||
'**/__tests__/**/*.ts',
|
||||
'**/__tests__/**/*.tsx',
|
||||
'**/__mocks__/**/*.ts',
|
||||
'**/__mocks__/**/*.tsx',
|
||||
'*.test.@(ts|tsx|js|jsx)',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
@@ -755,7 +266,6 @@ export default [
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
"libsDir": "packages"
|
||||
},
|
||||
"namedInputs": {
|
||||
"default": [
|
||||
"{projectRoot}/**/*"
|
||||
],
|
||||
"default": ["{projectRoot}/**/*"],
|
||||
"excludeStories": [
|
||||
"default",
|
||||
"!{projectRoot}/.storybook/*",
|
||||
@@ -34,26 +32,17 @@
|
||||
"targetDefaults": {
|
||||
"build": {
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"^production",
|
||||
"production"
|
||||
],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"inputs": ["^production", "production"],
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"start": {
|
||||
"cache": false,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"cache": true,
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
],
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"eslintConfig": "{projectRoot}/eslint.config.mjs",
|
||||
"cache": true,
|
||||
@@ -67,22 +56,7 @@
|
||||
"fix": true
|
||||
}
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"lint:diff-with-main": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": false,
|
||||
"options": {
|
||||
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs \"$@\"; fi' _",
|
||||
"pattern": "\\.(ts|tsx|js|jsx)$"
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs --fix \"$@\"; fi' _"
|
||||
}
|
||||
}
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"fmt": {
|
||||
"executor": "nx:run-commands",
|
||||
@@ -103,9 +77,7 @@
|
||||
"write": true
|
||||
}
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
@@ -119,30 +91,22 @@
|
||||
"watch": true
|
||||
}
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeStories",
|
||||
"{workspaceRoot}/jest.preset.js"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/coverage"
|
||||
],
|
||||
"outputs": ["{projectRoot}/coverage"],
|
||||
"options": {
|
||||
"jestConfig": "{projectRoot}/jest.config.mjs",
|
||||
"coverage": true,
|
||||
"coverageReporters": [
|
||||
"text-summary"
|
||||
],
|
||||
"coverageReporters": ["text-summary"],
|
||||
"cacheDirectory": "../../.cache/jest/{projectRoot}"
|
||||
},
|
||||
"configurations": {
|
||||
@@ -151,10 +115,7 @@
|
||||
"maxWorkers": 3
|
||||
},
|
||||
"coverage": {
|
||||
"coverageReporters": [
|
||||
"lcov",
|
||||
"text"
|
||||
]
|
||||
"coverageReporters": ["lcov", "text"]
|
||||
},
|
||||
"watch": {
|
||||
"watch": true
|
||||
@@ -163,36 +124,25 @@
|
||||
},
|
||||
"test:e2e": {
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"storybook:build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeTests"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/{options.output-dir}"
|
||||
],
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"outputs": ["{projectRoot}/{options.output-dir}"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "NODE_OPTIONS='--max-old-space-size=10240' VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
|
||||
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
|
||||
"output-dir": "storybook-static",
|
||||
"config-dir": ".storybook"
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"storybook:serve:dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"dependsOn": ["^build"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "storybook dev",
|
||||
@@ -201,9 +151,7 @@
|
||||
},
|
||||
"storybook:serve:static": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": [
|
||||
"storybook:build"
|
||||
],
|
||||
"dependsOn": ["storybook:build"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx http-server {args.staticDir} -a={args.host} --port={args.port} --silent={args.silent}",
|
||||
@@ -216,13 +164,8 @@
|
||||
"storybook:test": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeTests"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/coverage/storybook"
|
||||
],
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"outputs": ["{projectRoot}/coverage/storybook"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"commands": [
|
||||
@@ -238,10 +181,7 @@
|
||||
},
|
||||
"storybook:test:no-coverage": {
|
||||
"executor": "nx:run-commands",
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeTests"
|
||||
],
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"commands": [
|
||||
@@ -331,20 +271,12 @@
|
||||
},
|
||||
"@nx/vite:test": {
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"default",
|
||||
"^default"
|
||||
]
|
||||
"inputs": ["default", "^default"]
|
||||
},
|
||||
"@nx/vite:build": {
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"inputs": [
|
||||
"default",
|
||||
"^default"
|
||||
]
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["default", "^default"]
|
||||
}
|
||||
},
|
||||
"installation": {
|
||||
@@ -376,9 +308,7 @@
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
"options": {
|
||||
"cacheableOperations": [
|
||||
"storybook:build"
|
||||
]
|
||||
"cacheableOperations": ["storybook:build"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+29
-16
@@ -2,12 +2,15 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.17",
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@floating-ui/react": "^0.24.3",
|
||||
"@linaria/core": "^6.2.0",
|
||||
"@linaria/react": "^6.2.1",
|
||||
"@radix-ui/colors": "^3.0.0",
|
||||
"@sentry/profiling-node": "^9.26.0",
|
||||
"@sentry/react": "^9.26.0",
|
||||
"@sniptt/guards": "^0.2.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@wyw-in-js/vite": "^0.7.0",
|
||||
@@ -52,7 +55,7 @@
|
||||
"rxjs": "^7.2.0",
|
||||
"semver": "^7.5.4",
|
||||
"slash": "^5.1.0",
|
||||
"temporal-polyfill": "^0.3.0",
|
||||
"storybook-addon-mock-date": "^0.6.0",
|
||||
"ts-key-enum": "^2.0.12",
|
||||
"tslib": "^2.8.1",
|
||||
"type-fest": "4.10.1",
|
||||
@@ -66,7 +69,7 @@
|
||||
"@babel/core": "^7.14.5",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@babel/preset-typescript": "^7.24.6",
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@chromatic-com/storybook": "^3",
|
||||
"@graphql-codegen/cli": "^3.3.1",
|
||||
"@graphql-codegen/client-preset": "^4.1.0",
|
||||
"@graphql-codegen/typescript": "^3.0.4",
|
||||
@@ -81,20 +84,26 @@
|
||||
"@nx/vite": "22.0.3",
|
||||
"@nx/web": "22.0.3",
|
||||
"@sentry/types": "^8",
|
||||
"@storybook-community/storybook-addon-cookie": "^5.0.0",
|
||||
"@storybook/addon-coverage": "^3.0.0",
|
||||
"@storybook/addon-docs": "^10.1.11",
|
||||
"@storybook/addon-links": "^10.1.11",
|
||||
"@storybook/icons": "^2.0.1",
|
||||
"@storybook/react-vite": "^10.1.11",
|
||||
"@storybook/test-runner": "^0.24.2",
|
||||
"@storybook/addon-actions": "8.6.14",
|
||||
"@storybook/addon-coverage": "^1.0.0",
|
||||
"@storybook/addon-essentials": "8.6.14",
|
||||
"@storybook/addon-interactions": "8.6.14",
|
||||
"@storybook/addon-links": "8.6.14",
|
||||
"@storybook/blocks": "8.6.14",
|
||||
"@storybook/core-server": "8.6.14",
|
||||
"@storybook/icons": "^1.2.9",
|
||||
"@storybook/preview-api": "8.6.14",
|
||||
"@storybook/react": "8.6.14",
|
||||
"@storybook/react-vite": "8.6.14",
|
||||
"@storybook/test": "8.6.14",
|
||||
"@storybook/test-runner": "^0.23.0",
|
||||
"@storybook/types": "8.6.14",
|
||||
"@stylistic/eslint-plugin": "^1.5.0",
|
||||
"@swc-node/register": "1.8.0",
|
||||
"@swc/cli": "^0.3.12",
|
||||
"@swc/core": "1.13.3",
|
||||
"@swc/helpers": "~0.5.2",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
@@ -102,6 +111,7 @@
|
||||
"@types/bytes": "^3.1.1",
|
||||
"@types/chrome": "^0.0.267",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/graphql-fields": "^1.3.6",
|
||||
"@types/inquirer": "^9.0.9",
|
||||
@@ -154,7 +164,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"eslint-plugin-storybook": "^10.1.11",
|
||||
"eslint-plugin-storybook": "^0.9.0",
|
||||
"eslint-plugin-unicorn": "^56.0.1",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"http-server": "^14.1.1",
|
||||
@@ -170,16 +180,20 @@
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.5",
|
||||
"source-map-support": "^0.5.20",
|
||||
"storybook": "^10.1.11",
|
||||
"storybook-addon-mock-date": "2.0.0",
|
||||
"storybook-addon-pseudo-states": "^10.1.11",
|
||||
"storybook": "8.6.14",
|
||||
"storybook-addon-cookie": "^3.2.0",
|
||||
"storybook-addon-pseudo-states": "^2.1.2",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.17.0",
|
||||
"vite": "^7.0.0"
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-checker": "^0.10.2",
|
||||
"vite-plugin-cjs-interop": "^2.2.0",
|
||||
"vite-plugin-dts": "3.8.1",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -219,7 +233,6 @@
|
||||
"packages/twenty-sdk",
|
||||
"packages/twenty-apps",
|
||||
"packages/twenty-cli",
|
||||
"packages/create-twenty-app",
|
||||
"tools/eslint-rules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<div align="center">
|
||||
<a href="https://twenty.com">
|
||||
<picture>
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg" height="128">
|
||||
</picture>
|
||||
</a>
|
||||
<h1>Create Twenty App</h1>
|
||||
|
||||
<a href="https://www.npmjs.com/package/create-twenty-app"><img alt="NPM version" src="https://img.shields.io/npm/v/create-twenty-app.svg?style=for-the-badge&labelColor=000000"></a>
|
||||
<a href="https://github.com/twentyhq/twenty/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/npm/l/next.svg?style=for-the-badge&labelColor=000000"></a>
|
||||
<a href="https://discord.gg/cx5n4Jzs57"><img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-blueviolet.svg?style=for-the-badge&logo=Twenty&labelColor=000000&logoWidth=20"></a>
|
||||
|
||||
</div>
|
||||
|
||||
Create Twenty App is the official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). It sets up a ready‑to‑run project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
|
||||
|
||||
- Zero‑config project bootstrap
|
||||
- Preconfigured scripts for auth, generate, dev sync, one‑off sync, uninstall
|
||||
- Strong TypeScript support and typed client generation
|
||||
|
||||
## Prerequisites
|
||||
- Node.js 24+ (recommended) and Yarn 4
|
||||
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Authenticate using your API key (you'll be prompted)
|
||||
yarn auth
|
||||
|
||||
# Add a new entity to your application (guided)
|
||||
yarn create-entity
|
||||
|
||||
# Generate a typed Twenty client and workspace entity types
|
||||
yarn generate
|
||||
|
||||
# Start dev mode: automatically syncs local changes to your workspace
|
||||
yarn dev
|
||||
|
||||
# Or run a one‑time sync
|
||||
yarn sync
|
||||
|
||||
# Watch your application's functions logs
|
||||
yarn logs
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn uninstall
|
||||
|
||||
# Display commands' help
|
||||
yarn help
|
||||
```
|
||||
|
||||
## What gets scaffolded
|
||||
- A minimal app structure ready for Twenty
|
||||
- TypeScript configuration
|
||||
- Prewired scripts that wrap the `twenty` CLI from twenty-sdk
|
||||
- Example placeholders to help you add entities, actions, and sync logic
|
||||
|
||||
## Next steps
|
||||
- Explore the generated project and add your first entity with `yarn create-entity`.
|
||||
- Keep your types up‑to‑date using `yarn generate`.
|
||||
- Use `yarn dev` while you iterate to see changes instantly in your workspace.
|
||||
|
||||
|
||||
## Publish your application
|
||||
Applications are currently stored in `twenty/packages/twenty-apps`.
|
||||
|
||||
You can share your application with all Twenty users:
|
||||
|
||||
```bash
|
||||
# pull the Twenty project
|
||||
git clone https://github.com/twentyhq/twenty.git
|
||||
cd twenty
|
||||
|
||||
# create a new branch
|
||||
git checkout -b feature/my-awesome-app
|
||||
```
|
||||
|
||||
- Copy your app folder into `twenty/packages/twenty-apps`.
|
||||
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
|
||||
|
||||
```bash
|
||||
git commit -m "Add new application"
|
||||
git push
|
||||
```
|
||||
|
||||
Our team reviews contributions for quality, security, and reusability before merging.
|
||||
|
||||
## Troubleshooting
|
||||
- Auth prompts not appearing: run `yarn auth` again and verify the API key permissions.
|
||||
- Types not generated: ensure `yarn generate` runs without errors, then re‑start `yarn dev`.
|
||||
|
||||
## Contributing
|
||||
- See our [GitHub](https://github.com/twentyhq/twenty)
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.3.1",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npx rimraf dist && npx vite build"
|
||||
},
|
||||
"keywords": [
|
||||
"twenty",
|
||||
"cli",
|
||||
"crm",
|
||||
"application",
|
||||
"development"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/cli.d.ts",
|
||||
"import": "./dist/cli.mjs",
|
||||
"require": "./dist/cli.cjs"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@genql/cli": "^3.0.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"inquirer": "^10.0.0",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"lodash.startcase": "^4.4.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
"@types/inquirer": "^9.0.0",
|
||||
"@types/lodash.camelcase": "^4.3.7",
|
||||
"@types/lodash.kebabcase": "^4.1.7",
|
||||
"@types/lodash.startcase": "^4",
|
||||
"@types/node": "^20.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"yarn": "^4.0.2"
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:create-app"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["{projectRoot}/dist"]
|
||||
},
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"cwd": "packages/create-twenty-app",
|
||||
"command": "tsx src/cli.ts"
|
||||
}
|
||||
},
|
||||
"start": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"cwd": "packages/create-twenty-app",
|
||||
"command": "node dist/cli.cjs"
|
||||
}
|
||||
},
|
||||
"typecheck": {},
|
||||
"lint": {
|
||||
"options": {
|
||||
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
|
||||
"maxWarnings": 0
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
|
||||
"maxWarnings": 0
|
||||
},
|
||||
"fix": {}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "{projectRoot}/jest.config.mjs"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"coverage": true,
|
||||
"watchAll": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommanderError } from 'commander';
|
||||
import { CreateAppCommand } from '@/create-app.command';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const program = new Command(packageJson.name)
|
||||
.description('CLI tool to initialize a new Twenty application')
|
||||
.version(
|
||||
packageJson.version,
|
||||
'-v, --version',
|
||||
'Output the current version of create-twenty-app.',
|
||||
)
|
||||
.argument('[directory]')
|
||||
.helpOption('-h, --help', 'Display this help message.')
|
||||
.action(async (directory?: string) => {
|
||||
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
await new CreateAppCommand().execute(directory);
|
||||
});
|
||||
|
||||
program.exitOverride();
|
||||
|
||||
try {
|
||||
program.parse();
|
||||
} catch (error) {
|
||||
if (error instanceof CommanderError) {
|
||||
process.exit(error.exitCode);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
console.error(chalk.red('Error:'), error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
24.5.0
|
||||
Binary file not shown.
@@ -1,27 +0,0 @@
|
||||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn auth
|
||||
```
|
||||
|
||||
Then, install this app to your workspace:
|
||||
|
||||
```bash
|
||||
yarn sync
|
||||
```
|
||||
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Twenty applications, take a look at the following resources:
|
||||
|
||||
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
|
||||
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
|
||||
@@ -1,29 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
// Base JS recommended rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript recommended rules
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Common TypeScript-friendly tweaks
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-unused-vars': 'off', // handled by TS rule
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import * as path from 'path';
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
import kebabCase from 'lodash.kebabcase';
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
import { tryGitInit } from '@/utils/try-git-init';
|
||||
import { install } from '@/utils/install';
|
||||
|
||||
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
|
||||
|
||||
export class CreateAppCommand {
|
||||
async execute(directory?: string): Promise<void> {
|
||||
try {
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
await this.getAppInfos(directory);
|
||||
|
||||
await this.validateDirectory(appDirectory);
|
||||
|
||||
this.logCreationInfo({ appDirectory, appName });
|
||||
|
||||
await fs.ensureDir(appDirectory);
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
|
||||
await install(appDirectory);
|
||||
|
||||
await tryGitInit(appDirectory);
|
||||
|
||||
this.logSuccess(appDirectory);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Initialization failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppInfos(directory?: string): Promise<{
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}> {
|
||||
const { name, displayName, description } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Application name:',
|
||||
when: () => !directory,
|
||||
default: 'my-awesome-app',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) return 'Application name is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'displayName',
|
||||
message: 'Application display name:',
|
||||
default: (answers: any) => {
|
||||
return convertToLabel(answers?.name ?? directory);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'description',
|
||||
message: 'Application description (optional):',
|
||||
default: '',
|
||||
},
|
||||
]);
|
||||
|
||||
const computedName = name ?? directory;
|
||||
|
||||
const appName = computedName.trim();
|
||||
|
||||
const appDisplayName = displayName.trim();
|
||||
|
||||
const appDescription = description.trim();
|
||||
|
||||
const appDirectory = directory
|
||||
? path.join(CURRENT_EXECUTION_DIRECTORY, directory)
|
||||
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
|
||||
|
||||
return { appName, appDisplayName, appDirectory, appDescription };
|
||||
}
|
||||
|
||||
private async validateDirectory(appDirectory: string): Promise<void> {
|
||||
if (!(await fs.pathExists(appDirectory))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.readdir(appDirectory);
|
||||
if (files.length > 0) {
|
||||
throw new Error(
|
||||
`Directory ${appDirectory} already exists and is not empty`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private logCreationInfo({
|
||||
appDirectory,
|
||||
appName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
appName: string;
|
||||
}): void {
|
||||
console.log(chalk.blue('🎯 Creating Twenty Application'));
|
||||
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
|
||||
console.log(chalk.gray(`📝 Name: ${appName}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
private logSuccess(appDirectory: string): void {
|
||||
console.log(chalk.green('✅ Application created!'));
|
||||
console.log('');
|
||||
console.log(chalk.blue('Next steps:'));
|
||||
console.log(`cd ${appDirectory.split('/').reverse()[0] ?? ''}`);
|
||||
console.log('yarn auth');
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
|
||||
// Mock fs-extra's copy function to skip copying base template (not available during tests)
|
||||
jest.mock('fs-extra', () => {
|
||||
const actual = jest.requireActual('fs-extra');
|
||||
return {
|
||||
...actual,
|
||||
copy: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
describe('copyBaseApplicationProject', () => {
|
||||
let testAppDirectory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique temp directory for each test
|
||||
testAppDirectory = join(
|
||||
tmpdir(),
|
||||
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
await fs.ensureDir(testAppDirectory);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temp directory after each test
|
||||
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
|
||||
await fs.remove(testAppDirectory);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create the correct folder structure with src/app/', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
// Verify src/app/ folder exists
|
||||
const srcAppPath = join(testAppDirectory, 'src', 'app');
|
||||
expect(await fs.pathExists(srcAppPath)).toBe(true);
|
||||
|
||||
// Verify application.config.ts exists in src/app/
|
||||
const appConfigPath = join(srcAppPath, 'application.config.ts');
|
||||
expect(await fs.pathExists(appConfigPath)).toBe(true);
|
||||
|
||||
// Verify default-function.role.ts exists in src/app/
|
||||
const roleConfigPath = join(srcAppPath, 'default-function.role.ts');
|
||||
expect(await fs.pathExists(roleConfigPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should create package.json with correct content', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const packageJsonPath = join(testAppDirectory, 'package.json');
|
||||
expect(await fs.pathExists(packageJsonPath)).toBe(true);
|
||||
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
expect(packageJson.name).toBe('my-test-app');
|
||||
expect(packageJson.version).toBe('0.1.0');
|
||||
expect(packageJson.dependencies['twenty-sdk']).toBe('0.3.1');
|
||||
expect(packageJson.scripts.sync).toBe('twenty app sync');
|
||||
expect(packageJson.scripts.dev).toBe('twenty app dev');
|
||||
});
|
||||
|
||||
it('should create .gitignore file', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const gitignorePath = join(testAppDirectory, '.gitignore');
|
||||
expect(await fs.pathExists(gitignorePath)).toBe(true);
|
||||
|
||||
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
||||
expect(gitignoreContent).toContain('/node_modules');
|
||||
expect(gitignoreContent).toContain('generated');
|
||||
});
|
||||
|
||||
it('should create yarn.lock file', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const yarnLockPath = join(testAppDirectory, 'yarn.lock');
|
||||
expect(await fs.pathExists(yarnLockPath)).toBe(true);
|
||||
|
||||
const yarnLockContent = await fs.readFile(yarnLockPath, 'utf8');
|
||||
expect(yarnLockContent).toContain('yarn lockfile v1');
|
||||
});
|
||||
|
||||
it('should create application.config.ts with defineApp and correct values', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const appConfigPath = join(
|
||||
testAppDirectory,
|
||||
'src',
|
||||
'app',
|
||||
'application.config.ts',
|
||||
);
|
||||
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
|
||||
|
||||
// Verify it uses defineApp
|
||||
expect(appConfigContent).toContain(
|
||||
"import { defineApp } from 'twenty-sdk'",
|
||||
);
|
||||
expect(appConfigContent).toContain('export default defineApp({');
|
||||
|
||||
// Verify it imports the role identifier
|
||||
expect(appConfigContent).toContain(
|
||||
"import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role'",
|
||||
);
|
||||
|
||||
// Verify display name and description
|
||||
expect(appConfigContent).toContain("displayName: 'My Test App'");
|
||||
expect(appConfigContent).toContain("description: 'A test application'");
|
||||
|
||||
// Verify it has a universalIdentifier (UUID format)
|
||||
expect(appConfigContent).toMatch(
|
||||
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
|
||||
);
|
||||
|
||||
// Verify it references the role
|
||||
expect(appConfigContent).toContain(
|
||||
'functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create default-function.role.ts with defineRole and correct values', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const roleConfigPath = join(
|
||||
testAppDirectory,
|
||||
'src',
|
||||
'app',
|
||||
'default-function.role.ts',
|
||||
);
|
||||
const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8');
|
||||
|
||||
// Verify it uses defineRole
|
||||
expect(roleConfigContent).toContain(
|
||||
"import { defineRole } from 'twenty-sdk'",
|
||||
);
|
||||
expect(roleConfigContent).toContain('export default defineRole({');
|
||||
|
||||
// Verify it exports the universal identifier constant
|
||||
expect(roleConfigContent).toContain(
|
||||
'export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER',
|
||||
);
|
||||
|
||||
// Verify role label includes app name
|
||||
expect(roleConfigContent).toContain(
|
||||
"label: 'My Test App default function role'",
|
||||
);
|
||||
|
||||
// Verify default permissions
|
||||
expect(roleConfigContent).toContain('canReadAllObjectRecords: true');
|
||||
expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true');
|
||||
expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true');
|
||||
expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false');
|
||||
|
||||
// Verify it has a universalIdentifier (UUID format)
|
||||
expect(roleConfigContent).toMatch(
|
||||
/universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call fs.copy to copy base application template', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
// Verify fs.copy was called with correct destination
|
||||
expect(fs.copy).toHaveBeenCalledTimes(1);
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('base-application'),
|
||||
testAppDirectory,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty description', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: '',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const appConfigPath = join(
|
||||
testAppDirectory,
|
||||
'src',
|
||||
'app',
|
||||
'application.config.ts',
|
||||
);
|
||||
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
|
||||
|
||||
expect(appConfigContent).toContain("description: ''");
|
||||
});
|
||||
|
||||
it('should generate unique UUIDs for each application', async () => {
|
||||
// Create first app
|
||||
const firstAppDir = join(testAppDirectory, 'app1');
|
||||
await fs.ensureDir(firstAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-one',
|
||||
appDisplayName: 'App One',
|
||||
appDescription: 'First app',
|
||||
appDirectory: firstAppDir,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-two',
|
||||
appDisplayName: 'App Two',
|
||||
appDescription: 'Second app',
|
||||
appDirectory: secondAppDir,
|
||||
});
|
||||
|
||||
// Read both app configs
|
||||
const firstAppConfig = await fs.readFile(
|
||||
join(firstAppDir, 'src', 'app', 'application.config.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const secondAppConfig = await fs.readFile(
|
||||
join(secondAppDir, 'src', 'app', 'application.config.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Extract UUIDs using regex
|
||||
const uuidRegex =
|
||||
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
|
||||
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
|
||||
const secondUuid = secondAppConfig.match(uuidRegex)?.[1];
|
||||
|
||||
expect(firstUuid).toBeDefined();
|
||||
expect(secondUuid).toBeDefined();
|
||||
expect(firstUuid).not.toBe(secondUuid);
|
||||
});
|
||||
|
||||
it('should generate unique role UUIDs for each application', async () => {
|
||||
// Create first app
|
||||
const firstAppDir = join(testAppDirectory, 'app1');
|
||||
await fs.ensureDir(firstAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-one',
|
||||
appDisplayName: 'App One',
|
||||
appDescription: 'First app',
|
||||
appDirectory: firstAppDir,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-two',
|
||||
appDisplayName: 'App Two',
|
||||
appDescription: 'Second app',
|
||||
appDirectory: secondAppDir,
|
||||
});
|
||||
|
||||
// Read both role configs
|
||||
const firstRoleConfig = await fs.readFile(
|
||||
join(firstAppDir, 'src', 'app', 'default-function.role.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const secondRoleConfig = await fs.readFile(
|
||||
join(secondAppDir, 'src', 'app', 'default-function.role.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Extract UUIDs using regex
|
||||
const uuidRegex =
|
||||
/DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
|
||||
const firstUuid = firstRoleConfig.match(uuidRegex)?.[1];
|
||||
const secondUuid = secondRoleConfig.match(uuidRegex)?.[1];
|
||||
|
||||
expect(firstUuid).toBeDefined();
|
||||
expect(secondUuid).toBeDefined();
|
||||
expect(firstUuid).not.toBe(secondUuid);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
|
||||
describe('convertToLabel', () => {
|
||||
it('should convert to label', () => {
|
||||
expect(convertToLabel('toto')).toBe('Toto');
|
||||
expect(convertToLabel('totoTata')).toBe('Toto tata');
|
||||
expect(convertToLabel('totoTataTiti')).toBe('Toto tata titi');
|
||||
expect(convertToLabel('toto-tata-titi')).toBe('Toto tata titi');
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
const APP_FOLDER = 'src/app';
|
||||
|
||||
export const copyBaseApplicationProject = async ({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
}: {
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
|
||||
|
||||
await createPackageJson({ appName, appDirectory });
|
||||
|
||||
await createGitignore(appDirectory);
|
||||
|
||||
await createYarnLock(appDirectory);
|
||||
|
||||
const appFolderPath = join(appDirectory, APP_FOLDER);
|
||||
|
||||
await fs.ensureDir(appFolderPath);
|
||||
|
||||
await createDefaultServerlessFunctionRoleConfig({
|
||||
displayName: appDisplayName,
|
||||
appDirectory: appFolderPath,
|
||||
});
|
||||
|
||||
await createApplicationConfig({
|
||||
displayName: appDisplayName,
|
||||
description: appDescription,
|
||||
appDirectory: appFolderPath,
|
||||
});
|
||||
};
|
||||
|
||||
const createYarnLock = async (appDirectory: string) => {
|
||||
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
|
||||
};
|
||||
const createGitignore = async (appDirectory: string) => {
|
||||
const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
|
||||
};
|
||||
|
||||
const createDefaultServerlessFunctionRoleConfig = async ({
|
||||
displayName,
|
||||
appDirectory,
|
||||
}: {
|
||||
displayName: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineRole } from 'twenty-sdk';
|
||||
|
||||
export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: '${displayName} default function role',
|
||||
description: '${displayName} default function role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'default-function.role.ts'), content);
|
||||
};
|
||||
|
||||
const createApplicationConfig = async ({
|
||||
displayName,
|
||||
description,
|
||||
appDirectory,
|
||||
}: {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
const content = `import { defineApp } from 'twenty-sdk';
|
||||
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role';
|
||||
|
||||
export default defineApp({
|
||||
universalIdentifier: '${v4()}',
|
||||
displayName: '${displayName}',
|
||||
description: '${description ?? ''}',
|
||||
functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'application.config.ts'), content);
|
||||
};
|
||||
|
||||
const createPackageJson = async ({
|
||||
appName,
|
||||
appDirectory,
|
||||
}: {
|
||||
appName: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
const packageJson = {
|
||||
name: appName,
|
||||
version: '0.1.0',
|
||||
license: 'MIT',
|
||||
engines: {
|
||||
node: '^24.5.0',
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
packageManager: '[email protected]',
|
||||
scripts: {
|
||||
'create-entity': 'twenty app add',
|
||||
dev: 'twenty app dev',
|
||||
generate: 'twenty app generate',
|
||||
sync: 'twenty app sync',
|
||||
logs: 'twenty app logs',
|
||||
uninstall: 'twenty app uninstall',
|
||||
help: 'twenty help',
|
||||
auth: 'twenty auth login',
|
||||
lint: 'eslint',
|
||||
'lint-fix': 'eslint --fix',
|
||||
},
|
||||
dependencies: {
|
||||
'twenty-sdk': '0.3.1',
|
||||
},
|
||||
devDependencies: {
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
eslint: '^9.32.0',
|
||||
'typescript-eslint': '^8.50.0',
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
join(appDirectory, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
export const install = async (root: string) => {
|
||||
try {
|
||||
await execPromise('yarn', { cwd: root });
|
||||
} catch (error: any) {
|
||||
console.error(chalk.red('yarn install failed:'), error.stdout);
|
||||
}
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
const isInGitRepository = async (root: string): Promise<boolean> => {
|
||||
try {
|
||||
await execPromise('git rev-parse --is-inside-work-tree', { cwd: root });
|
||||
return true;
|
||||
} catch {
|
||||
// Empty catch block
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isInMercurialRepository = async (root: string): Promise<boolean> => {
|
||||
try {
|
||||
await execPromise('hg --cwd . root', { cwd: root });
|
||||
return true;
|
||||
} catch {
|
||||
// Empty catch block
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isDefaultBranchSet = async (root: string): Promise<boolean> => {
|
||||
try {
|
||||
await execPromise('git config init.defaultBranch', { cwd: root });
|
||||
return true;
|
||||
} catch {
|
||||
// Empty catch block
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const tryGitInit = async (root: string): Promise<boolean> => {
|
||||
try {
|
||||
await execPromise('git --version', { cwd: root });
|
||||
|
||||
if (
|
||||
(await isInGitRepository(root)) ||
|
||||
(await isInMercurialRepository(root))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
await execPromise('git init', { cwd: root });
|
||||
|
||||
try {
|
||||
if (!(await isDefaultBranchSet(root))) {
|
||||
await execPromise('git checkout -b main', { cwd: root });
|
||||
}
|
||||
|
||||
await execPromise('git add -A', { cwd: root });
|
||||
await execPromise(
|
||||
'git commit -m "Initial commit from Create Twenty App"',
|
||||
{
|
||||
cwd: root,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
fs.rm(join(root, '.git'), { recursive: true, force: true });
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strictNullChecks": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/__tests__/**"
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"**/__mocks__/**/*",
|
||||
"vite.config.ts",
|
||||
"jest.config.mjs",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx"
|
||||
]
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import packageJson from './package.json';
|
||||
|
||||
const moduleEntries = Object.keys((packageJson as any).exports || {})
|
||||
.filter(
|
||||
(key) => key !== './style.css' && key !== '.' && !key.startsWith('./src/'),
|
||||
)
|
||||
.map((module) => `src/${module.replace(/^\.\//, '')}/index.ts`);
|
||||
|
||||
const entries = ['src/cli.ts', ...moduleEntries];
|
||||
|
||||
const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
|
||||
if (!chunk.isEntry) {
|
||||
throw new Error(
|
||||
`Should never occurs, encountered a non entry chunk ${chunk.facadeModuleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
|
||||
if (splitFaceModuleId === undefined) {
|
||||
throw new Error(
|
||||
`Should never occurs splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
|
||||
if (moduleDirectory === 'src') {
|
||||
return `${chunk.name}.${extension}`;
|
||||
}
|
||||
return `${moduleDirectory}.${extension}`;
|
||||
};
|
||||
|
||||
const copyAssetPlugin = (targets: { src: string; dest: string }[]) => {
|
||||
return {
|
||||
name: 'copy-assets',
|
||||
closeBundle: async () => {
|
||||
for (const target of targets) {
|
||||
await fs.copy(
|
||||
path.resolve(__dirname, target.src),
|
||||
path.resolve(__dirname, target.dest),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default defineConfig(() => {
|
||||
const tsConfigPath = path.resolve(__dirname, './tsconfig.lib.json');
|
||||
|
||||
return {
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/create-twenty-app',
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: __dirname,
|
||||
}),
|
||||
dts({ entryRoot: './src', tsconfigPath: tsConfigPath }),
|
||||
copyAssetPlugin([
|
||||
{
|
||||
src: 'src/constants/base-application',
|
||||
dest: 'dist/constants/base-application',
|
||||
},
|
||||
]),
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
lib: { entry: entries, name: 'create-twenty-app' },
|
||||
rollupOptions: {
|
||||
external: [
|
||||
...Object.keys((packageJson as any).dependencies || {}),
|
||||
'path',
|
||||
'fs',
|
||||
'child_process',
|
||||
],
|
||||
output: [
|
||||
{
|
||||
format: 'es',
|
||||
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
|
||||
},
|
||||
{
|
||||
format: 'cjs',
|
||||
interop: 'auto',
|
||||
esModule: true,
|
||||
exports: 'named',
|
||||
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
logLevel: 'warn',
|
||||
};
|
||||
});
|
||||
@@ -17,7 +17,7 @@ FIREFLIES_API_KEY=your_fireflies_api_key_here
|
||||
# Fireflies plan level - affects which fields are available
|
||||
# Options: free, pro, business, enterprise
|
||||
# This controls which GraphQL fields are requested to avoid 403 errors
|
||||
FIREFLIES_PLAN=free
|
||||
FIREFLIES_PLAN_LEVEL=pro
|
||||
|
||||
# Twenty CRM API key for authentication
|
||||
# Generate this from your Twenty instance: Settings > Developers > API Keys
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
.yarn
|
||||
Binary file not shown.
@@ -1,76 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [0.3.1] - 2025-12-08
|
||||
|
||||
Import all
|
||||
|
||||
### Added
|
||||
- Historical import CLI: `yarn meeting:all` to fetch and insert historical Fireflies meetings with filters (date range, organizers, participants, channel, mine) and dry-run support.
|
||||
- Fireflies transcripts listing with pagination and date filtering to support bulk imports.
|
||||
|
||||
### Changed
|
||||
- Deduplication now checks `firefliesMeetingId` before creating meetings (webhook + bulk).
|
||||
- Shared historical importer pipeline reusing existing note/meeting formatting.
|
||||
|
||||
## [0.3.0] - 2025-12-08
|
||||
|
||||
Subscription-based query / Full transcript and AI notes for Pro+ / More
|
||||
|
||||
### Added
|
||||
- **Full transcript capture**: Meeting object now stores the complete meeting transcript with speaker names and timestamps (`transcript` field)
|
||||
- **Rich AI meeting notes**: Captures detailed AI-generated meeting notes from Fireflies (`notes` field with 7,000+ char summaries)
|
||||
- **Expanded summary fields**: Now fetches all available Fireflies summary data:
|
||||
- `notes` - Detailed AI-generated meeting notes with timestamps and section headers
|
||||
- `bullet_gist` - Emoji-enhanced bullet point summaries
|
||||
- `outline` / `shorthand_bullet` - Timestamped meeting outline
|
||||
- `gist` - One-sentence meeting summary
|
||||
- `short_summary` - Single paragraph summary
|
||||
- `short_overview` - Brief overview
|
||||
- **New Meeting fields**:
|
||||
- `transcript` - Full meeting transcript with speaker attribution
|
||||
- `notes` - AI-generated detailed notes
|
||||
- `audioUrl` - Link to audio recording (Pro+)
|
||||
- `videoUrl` - Link to video recording (Business+)
|
||||
- `meetingLink` - Original meeting link
|
||||
- `neutralPercent` - Neutral sentiment percentage
|
||||
- **Meeting delete utility**: New `yarn meeting:delete <meetingId>` script for cleanup and re-import
|
||||
- **Debug meeting utility**: New `scripts/debug-meeting.ts` to inspect raw Fireflies API responses
|
||||
|
||||
### Changed
|
||||
- **Plan-based GraphQL queries**: Completely redesigned query system with three tiers:
|
||||
- **Free**: Basic fields only (title, date, duration, participants, transcript_url, meeting_link)
|
||||
- **Pro**: Adds full transcript (`sentences`), summary fields, speakers, audio_url
|
||||
- **Business+**: Adds analytics, video_url, speaker stats, meeting metrics
|
||||
- **Action items parsing**: Fixed parsing of `action_items` which Fireflies returns as newline-separated string, not array
|
||||
- **Note body format**: Enhanced with Meeting Notes, Outline, Key Points sections from rich Fireflies data
|
||||
- **Import status**: Added `PARTIAL` status for imports missing summary/analytics data
|
||||
|
||||
### Fixed
|
||||
- Missing `notes` and `bullet_gist` fields in data transform (were fetched but not passed through)
|
||||
- Proper fallback: Uses `shorthand_bullet` when `outline` is empty (Fireflies stores outline content there)
|
||||
- Summary readiness detection now checks `notes` field in addition to `overview` and `action_items`
|
||||
|
||||
### Documentation
|
||||
- Updated README with complete API access comparison table by subscription plan
|
||||
- Documented all available Fireflies summary fields and their plan requirements
|
||||
|
||||
## [0.2.3] - 2025-12-06
|
||||
|
||||
### Added
|
||||
- **Meeting ingest utility**: New `yarn meeting:ingest <meetingId>` script to manually fetch and import specific Fireflies meetings into Twenty
|
||||
- **Plan-based field selection**: Added `FIREFLIES_PLAN` configuration to control which GraphQL fields are requested based on subscription level (free, pro, business, enterprise)
|
||||
- **Main entry point**: New `src/index.ts` centralizing all exports for cleaner imports
|
||||
|
||||
### Changed
|
||||
- **Auth configuration**: Disabled authentication requirement for webhook route (`isAuthRequired: false`) to support serverless deployments
|
||||
- **Signature verification fallback**: Webhook handler now supports signature in payload body as fallback when HTTP headers aren't forwarded to serverless functions (production doesn't work for Fireflies webhook)
|
||||
- **Improved type safety**: Replaced `any` types with proper TypeScript types throughout codebase
|
||||
|
||||
### Enhanced
|
||||
- **Webhook debugging**: Added detailed debug output including param keys, header info, and signature comparison details
|
||||
- **Test webhook script**: Includes signature in both header and payload, with diagnostic output for header forwarding status
|
||||
- **Documentation**: Added README sections on current twenty headers forward limitations and utility scripts
|
||||
|
||||
## [0.2.2] - 2025-11-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
Automatically captures meeting notes with AI-generated summaries and insights from Fireflies.ai into your Twenty CRM.
|
||||
|
||||
### Current Status
|
||||
- Doesn't work with Fireflies webhook yet due to missing headers forwarding in twenty serverless func
|
||||
- Meeting ingestion utility scripts are available for individual meeting insertion and historical meetings with filters with yarn meeting:all
|
||||
|
||||
## Integration Overview
|
||||
|
||||
**Fireflies webhook → Fireflies API → Twenty CRM with summary-focused insights**
|
||||
@@ -23,54 +19,19 @@ Automatically captures meeting notes with AI-generated summaries and insights fr
|
||||
|
||||
## API Access by Subscription Plan
|
||||
|
||||
Fireflies API access varies by subscription tier. This integration automatically adapts queries based on your plan and falls back gracefully if restrictions are encountered.
|
||||
|
||||
### Plan Comparison
|
||||
Fireflies API access varies significantly by subscription tier:
|
||||
|
||||
| Feature | Free | Pro | Business | Enterprise |
|
||||
|---------|:----:|:---:|:--------:|:----------:|
|
||||
| **API Rate Limit** | 50/day | 50/day | 60/min | 60/min |
|
||||
| **Basic Data** (title, date, duration) | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Participants List** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Transcript URL** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Speakers** | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Summary** (overview, keywords) | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Audio URL** | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Action Items** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Topics Discussed** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Video URL** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Sentiment Analytics** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Meeting Attendees (detailed)** | ❌ | ❌ | ✅ | ✅ |
|
||||
|---------|------|-----|----------|------------|
|
||||
| **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 |
|
||||
|
||||
### What You'll Get Per Plan
|
||||
**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.
|
||||
|
||||
**Free Plan:**
|
||||
- Meeting title, date, duration
|
||||
- Participant names/emails (basic)
|
||||
- Link to transcript
|
||||
|
||||
**Pro Plan:**
|
||||
- Everything in Free, plus:
|
||||
- Speaker identification
|
||||
- AI summary (overview + keywords)
|
||||
- Audio recording URL
|
||||
|
||||
**Business Plan:**
|
||||
- Everything in Pro, plus:
|
||||
- Action items extraction
|
||||
- Topics discussed
|
||||
- Sentiment analysis (positive/negative/neutral %)
|
||||
- Video recording URL
|
||||
- Detailed meeting attendee info
|
||||
|
||||
### Configuration
|
||||
|
||||
Set your plan in `.env`:
|
||||
```bash
|
||||
FIREFLIES_PLAN=free # Options: free, pro, business, enterprise
|
||||
```
|
||||
|
||||
**Rate Limiting:** Free/Pro plans are limited to 50 API calls/day. The integration uses conservative retry settings by default to stay within limits.
|
||||
**Pro Plan Limitation:** Despite "unlimited" AI summaries, the 50 requests/day limit severely constrains production usage for meeting-heavy organizations.
|
||||
|
||||
## What Gets Captured
|
||||
|
||||
@@ -153,6 +114,11 @@ The `setup:fields` script adds 13 custom fields to store rich Fireflies data:
|
||||
| `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
|
||||
@@ -194,29 +160,6 @@ The integration uses **HMAC SHA-256 signature verification**:
|
||||
- Twenty verifies signature using your webhook secret
|
||||
- Invalid signatures are rejected immediately
|
||||
|
||||
### Current Platform Limitation (Headers)
|
||||
|
||||
- Twenty serverless route triggers currently do **not forward HTTP headers** to functions. Fireflies signatures sent in headers are stripped, so header-based verification does not work in production.
|
||||
- Workaround: the provided test script also includes the signature inside the payload; the handler falls back to that payload signature. Use this only for testing until header forwarding is supported.
|
||||
|
||||
## Utilities for meeting insertion (workarounds)
|
||||
|
||||
- Ingest a specific Fireflies meeting into Twenty:
|
||||
`yarn meeting:ingest <meetingId>` or `MEETING_ID=... yarn meeting:ingest`
|
||||
|
||||
- Fetch all/historical Fireflies meetings into Twenty:
|
||||
`yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer [email protected]] [--participant [email protected]] [--channel <channelId>] [--mine] [--dry-run]`
|
||||
|
||||
- Filters (combine as needed):
|
||||
- `--from` / `--to`: ISO or date string range filter
|
||||
- `--organizer` / `--participant`: comma-separated emails
|
||||
- `--channel`: Fireflies channel id
|
||||
- `--mine`: only meetings for the current Fireflies user
|
||||
- Controls:
|
||||
- `--dry-run`: list and transform without writing to Twenty
|
||||
- `--page-size`: pagination size (default 50)
|
||||
- `--max-records`: stop after N transcripts (default 500)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -304,7 +247,10 @@ Client expressed strong interest in the enterprise plan.
|
||||
|
||||
## Future Implementation Opportunities
|
||||
|
||||
Next iterations would enhance the **intelligence layer** to:
|
||||
### 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk';
|
||||
import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
|
||||
@@ -9,25 +9,17 @@ const config: ApplicationConfig = {
|
||||
FIREFLIES_WEBHOOK_SECRET: {
|
||||
universalIdentifier: 'f51f7646-be9f-4ba9-9b75-160dd288cd0c',
|
||||
description: 'Secret key for verifying Fireflies webhook signatures',
|
||||
//isSecret: true,
|
||||
value: '',
|
||||
isSecret: true,
|
||||
},
|
||||
FIREFLIES_API_KEY: {
|
||||
universalIdentifier: 'faa41f07-b28e-4500-b1c0-ce4b3d27924c',
|
||||
description: 'Fireflies GraphQL API key used to fetch meeting summaries',
|
||||
//isSecret: true,
|
||||
value: '',
|
||||
},
|
||||
FIREFLIES_PLAN: {
|
||||
universalIdentifier: '57dbb73c-aac5-4247-9fcc-a070bb669f16',
|
||||
description: 'Fireflies plan: free, pro, business, enterprise',
|
||||
value: 'free',
|
||||
isSecret: true,
|
||||
},
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: '02756551-5bf7-4fb2-8e08-1f622008d305',
|
||||
description: 'Twenty API key used when running scripts locally',
|
||||
//isSecret: true,
|
||||
value: '',
|
||||
isSecret: true,
|
||||
},
|
||||
SERVER_URL: {
|
||||
universalIdentifier: '9b3a5e8e-5973-4e6b-a059-2966075652aa',
|
||||
@@ -44,11 +36,6 @@ const config: ApplicationConfig = {
|
||||
description: 'Log level: silent, error, warn, info, debug (default: error)',
|
||||
value: 'error',
|
||||
},
|
||||
CAPTURE_LOGS: {
|
||||
universalIdentifier: 'adbcc267-309d-49b2-af71-76f1299d863e',
|
||||
description: 'Capture logs in webhook response for debugging (true/false)',
|
||||
value: 'true',
|
||||
},
|
||||
FIREFLIES_SUMMARY_STRATEGY: {
|
||||
universalIdentifier: '562b43d9-cd47-4ec1-ae16-5cc7ebc9729b',
|
||||
description: 'Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fireflies",
|
||||
"version": "0.3.0",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -11,15 +11,12 @@
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"setup:fields": "tsx scripts/add-meeting-fields.ts",
|
||||
"test:webhook": "tsx scripts/test-webhook.ts",
|
||||
"meeting:ingest": "tsx scripts/ingest-meeting.ts",
|
||||
"meeting:delete": "tsx scripts/delete-meeting.ts",
|
||||
"meeting:all": "tsx scripts/fetch-all-meetings.ts"
|
||||
"test:webhook": "tsx scripts/test-webhook.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"twenty-sdk": "0.1.3"
|
||||
"dotenv": "^16.3.1",
|
||||
"twenty-sdk": "0.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "fireflies",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/twenty-apps/community/fireflies/src",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/twenty-apps/fireflies/src",
|
||||
"projectType": "application",
|
||||
"tags": [
|
||||
"scope:apps"
|
||||
@@ -13,7 +13,7 @@
|
||||
"{workspaceRoot}/coverage/{projectRoot}"
|
||||
],
|
||||
"options": {
|
||||
"jestConfig": "packages/twenty-apps/community/fireflies/jest.config.mjs",
|
||||
"jestConfig": "packages/twenty-apps/fireflies/jest.config.mjs",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"configurations": {
|
||||
@@ -36,7 +36,7 @@
|
||||
],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"packages/twenty-apps/community/fireflies/**/*.{ts,tsx,js,jsx}"
|
||||
"packages/twenty-apps/fireflies/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
|
||||
@@ -50,11 +50,7 @@ interface FieldDefinition {
|
||||
options?: FieldOption[];
|
||||
}
|
||||
|
||||
// Meeting fields based on Fireflies GraphQL API transcript schema
|
||||
// See: https://docs.fireflies.ai/graphql-api/query/transcript
|
||||
// Note: Some fields require higher plans (Pro, Business, Enterprise)
|
||||
const MEETING_FIELDS: FieldDefinition[] = [
|
||||
// === Internal Twenty Relations ===
|
||||
{
|
||||
type: 'RELATION',
|
||||
name: 'note',
|
||||
@@ -63,21 +59,11 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
icon: 'IconNotes',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Basic Fields (All Plans) ===
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'firefliesMeetingId',
|
||||
label: 'Fireflies ID',
|
||||
description: 'Unique transcript ID from Fireflies (maps to: id)',
|
||||
icon: 'IconKey',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'DATE_TIME',
|
||||
name: 'meetingDate',
|
||||
label: 'Meeting Date',
|
||||
description: 'When the meeting occurred (maps to: date)',
|
||||
description: 'Date and time when the meeting occurred',
|
||||
icon: 'IconCalendar',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -85,107 +71,39 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
type: 'NUMBER',
|
||||
name: 'duration',
|
||||
label: 'Duration (minutes)',
|
||||
description: 'Meeting duration in minutes (maps to: duration)',
|
||||
description: 'Meeting duration in minutes',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'organizerEmail',
|
||||
label: 'Organizer Email',
|
||||
description: 'Meeting organizer email (maps to: organizer_email)',
|
||||
icon: 'IconMail',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'transcriptUrl',
|
||||
label: 'Transcript URL',
|
||||
description: 'Link to full transcript (maps to: transcript_url)',
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'meetingLink',
|
||||
label: 'Meeting Link',
|
||||
description: 'Original meeting link (maps to: meeting_link)',
|
||||
icon: 'IconLink',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Pro+ Fields (summary, speakers, audio_url, transcript) ===
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'transcript',
|
||||
label: 'Full Transcript',
|
||||
description: 'Full meeting transcript with speaker names and timestamps [Pro+]',
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'overview',
|
||||
label: 'Overview',
|
||||
description: 'AI-generated meeting summary (maps to: summary.overview) [Pro+]',
|
||||
icon: 'IconFileDescription',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'notes',
|
||||
label: 'AI Notes',
|
||||
description: 'Detailed AI-generated meeting notes (maps to: summary.notes) [Pro+]',
|
||||
icon: 'IconNotes',
|
||||
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 extracted (maps to: summary.keywords) [Pro+]',
|
||||
description: 'Key topics and themes discussed (comma-separated)',
|
||||
icon: 'IconTags',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'audioUrl',
|
||||
label: 'Audio URL',
|
||||
description: 'Link to audio recording (maps to: audio_url) [Pro+]',
|
||||
icon: 'IconHeadphones',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Business+ Fields (analytics, video_url, full summary) ===
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'meetingType',
|
||||
label: 'Meeting Type',
|
||||
description: 'AI-detected meeting type (maps to: summary.meeting_type) [Business+]',
|
||||
icon: 'IconTag',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'topics',
|
||||
label: 'Topics Discussed',
|
||||
description: 'Topics covered in meeting (maps to: summary.topics_discussed) [Business+]',
|
||||
icon: 'IconListDetails',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'actionItemsCount',
|
||||
label: 'Action Items',
|
||||
description: 'Number of action items (count of: summary.action_items) [Business+]',
|
||||
icon: 'IconCheckbox',
|
||||
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: 'Positive sentiment % (maps to: analytics.sentiments.positive_pct) [Business+]',
|
||||
description: 'Percentage of positive sentiment in conversation',
|
||||
icon: 'IconThumbUp',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -193,48 +111,69 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
type: 'NUMBER',
|
||||
name: 'negativePercent',
|
||||
label: 'Negative %',
|
||||
description: 'Negative sentiment % (maps to: analytics.sentiments.negative_pct) [Business+]',
|
||||
description: 'Percentage of negative sentiment in conversation',
|
||||
icon: 'IconThumbDown',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'neutralPercent',
|
||||
label: 'Neutral %',
|
||||
description: 'Neutral sentiment % (maps to: analytics.sentiments.neutral_pct) [Business+]',
|
||||
icon: 'IconMoodNeutral',
|
||||
name: 'actionItemsCount',
|
||||
label: 'Action Items',
|
||||
description: 'Number of action items identified',
|
||||
icon: 'IconCheckbox',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'videoUrl',
|
||||
label: 'Video URL',
|
||||
description: 'Link to video recording (maps to: video_url) [Business+]',
|
||||
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,
|
||||
},
|
||||
|
||||
// === Import Tracking Fields (Internal) ===
|
||||
{
|
||||
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 Fireflies import',
|
||||
description: 'Status of the meeting import from Fireflies',
|
||||
icon: 'IconCheck',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ value: 'SUCCESS', label: 'Success', position: 0, color: 'green' },
|
||||
{ value: 'PARTIAL', label: 'Partial', position: 1, color: 'blue' },
|
||||
{ value: 'FAILED', label: 'Failed', position: 2, color: 'red' },
|
||||
{ value: 'PENDING', label: 'Pending', position: 3, color: 'yellow' },
|
||||
{ value: 'RETRYING', label: 'Retrying', position: 4, color: 'orange' },
|
||||
{ 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 import failed',
|
||||
description: 'Error message if meeting import failed',
|
||||
icon: 'IconAlertTriangle',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -242,7 +181,7 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
type: 'DATE_TIME',
|
||||
name: 'lastImportAttempt',
|
||||
label: 'Last Import Attempt',
|
||||
description: 'When import was last attempted',
|
||||
description: 'Date and time of the last import attempt',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -250,7 +189,7 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
type: 'NUMBER',
|
||||
name: 'importAttempts',
|
||||
label: 'Import Attempts',
|
||||
description: 'Number of import attempts',
|
||||
description: 'Number of times import has been attempted',
|
||||
icon: 'IconRepeat',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -475,9 +414,13 @@ const main = async () => {
|
||||
}
|
||||
|
||||
if (createdCount === 0 && skippedCount === MEETING_FIELDS.length) {
|
||||
console.log('\n✨ All fields already exist. Nothing to do!\n');
|
||||
console.log('\n✨ All fields already exist. Nothing to do!');
|
||||
} else if (createdCount > 0) {
|
||||
console.log('\n✨ Custom fields added successfully!\n');
|
||||
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) {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/* 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);
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY;
|
||||
const meetingId = process.argv[2] || '01KBMR1ZYQ34YP8D2KB4B16QPH';
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
if (!FIREFLIES_API_KEY) {
|
||||
console.error('❌ FIREFLIES_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const query = `
|
||||
query GetTranscript($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
summary {
|
||||
overview
|
||||
notes
|
||||
gist
|
||||
bullet_gist
|
||||
short_summary
|
||||
short_overview
|
||||
outline
|
||||
shorthand_bullet
|
||||
action_items
|
||||
keywords
|
||||
topics_discussed
|
||||
meeting_type
|
||||
transcript_chapters
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await fetch('https://api.fireflies.ai/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${FIREFLIES_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables: { transcriptId: meetingId } }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ API request failed with status ${response.status}`);
|
||||
console.error(errorText);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
console.log('=== Fireflies API Response ===\n');
|
||||
console.log(JSON.stringify(json, null, 2));
|
||||
|
||||
if (json.data?.transcript?.summary) {
|
||||
const s = json.data.transcript.summary;
|
||||
console.log('\n=== Summary Fields Status ===');
|
||||
console.log('overview:', s.overview ? `✓ (${s.overview.length} chars)` : '✗ empty');
|
||||
console.log('notes:', s.notes ? `✓ (${s.notes.length} chars)` : '✗ empty');
|
||||
console.log('gist:', s.gist ? `✓ (${s.gist.length} chars)` : '✗ empty');
|
||||
console.log('bullet_gist:', s.bullet_gist ? `✓ (${s.bullet_gist.length} chars)` : '✗ empty');
|
||||
console.log('outline:', s.outline ? `✓ (${s.outline.length} chars)` : '✗ empty');
|
||||
console.log('action_items:', s.action_items?.length || 0, 'items');
|
||||
console.log('topics_discussed:', s.topics_discussed?.length || 0, 'topics');
|
||||
console.log('keywords:', s.keywords?.length || 0, 'keywords');
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to fetch meeting');
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/* 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);
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
|
||||
const API_KEY = process.env.TWENTY_API_KEY;
|
||||
const meetingId = process.argv[2];
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
if (!API_KEY) {
|
||||
console.error('❌ TWENTY_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!meetingId) {
|
||||
console.error('Usage: yarn delete:meeting <meetingId>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const response = await fetch(`${SERVER_URL}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `mutation DeleteMeeting($id: UUID!) { deleteMeeting(id: $id) { id } }`,
|
||||
variables: { id: meetingId },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Delete failed (status ${response.status})`);
|
||||
console.error(errorText);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const deletedId = result.data?.deleteMeeting?.id;
|
||||
if (result.errors || !deletedId) {
|
||||
const message = result.errors?.[0]?.message || 'deleteMeeting returned null';
|
||||
console.error('❌ Error:', message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ Deleted meeting:', deletedId);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to delete meeting');
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Fetch historical Fireflies meetings and insert into Twenty.
|
||||
*
|
||||
* Usage:
|
||||
* yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer [email protected]] [--participant [email protected]] [--channel <channelId>] [--mine] [--dry-run] [--page-size 50] [--max-records 200]
|
||||
*
|
||||
* Required env:
|
||||
* FIREFLIES_API_KEY
|
||||
* TWENTY_API_KEY
|
||||
*
|
||||
* Optional env:
|
||||
* SERVER_URL (defaults to http://localhost:3000)
|
||||
* FIREFLIES_PLAN (free|pro|business|enterprise)
|
||||
* AUTO_CREATE_CONTACTS (true|false)
|
||||
* FIREFLIES_* retry settings (see README)
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
import { existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { FirefliesApiClient } from '../src/fireflies-api-client';
|
||||
import { type HistoricalImportFilters, HistoricalImporter } from '../src/historical-importer';
|
||||
import { createLogger } from '../src/logger';
|
||||
import { TwentyCrmService } from '../src/twenty-crm-service';
|
||||
import {
|
||||
getApiUrl,
|
||||
getFirefliesPlan,
|
||||
getSummaryFetchConfig,
|
||||
shouldAutoCreateContacts,
|
||||
} from '../src/utils';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
|
||||
const logger = createLogger('cli:meeting:all');
|
||||
|
||||
type CliArgs = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
organizer?: string[];
|
||||
participant?: string[];
|
||||
channel?: string;
|
||||
host?: string;
|
||||
mine?: boolean;
|
||||
dryRun?: boolean;
|
||||
pageSize?: number;
|
||||
maxRecords?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const parseArgs = (argv: string[]): CliArgs => {
|
||||
const args: CliArgs = {};
|
||||
|
||||
const parseNumberArg = (value?: string): number | undefined => {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const current = argv[i];
|
||||
const next = argv[i + 1];
|
||||
switch (current) {
|
||||
case '--from':
|
||||
args.from = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--to':
|
||||
args.to = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--organizer':
|
||||
args.organizer = next ? next.split(',') : [];
|
||||
i += 1;
|
||||
break;
|
||||
case '--participant':
|
||||
args.participant = next ? next.split(',') : [];
|
||||
i += 1;
|
||||
break;
|
||||
case '--channel':
|
||||
args.channel = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--host':
|
||||
args.host = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--mine':
|
||||
args.mine = true;
|
||||
break;
|
||||
case '--dry-run':
|
||||
args.dryRun = true;
|
||||
break;
|
||||
case '--page-size':
|
||||
args.pageSize = parseNumberArg(next);
|
||||
i += 1;
|
||||
break;
|
||||
case '--max-records':
|
||||
args.maxRecords = parseNumberArg(next);
|
||||
i += 1;
|
||||
break;
|
||||
case '--limit':
|
||||
args.limit = parseNumberArg(next);
|
||||
i += 1;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
};
|
||||
|
||||
const parseDate = (value?: string): number | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
console.error('❌ FIREFLIES_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!twentyApiKey) {
|
||||
console.error('❌ TWENTY_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fromDate = parseDate(args.from);
|
||||
const toDate = parseDate(args.to);
|
||||
|
||||
const filters: HistoricalImportFilters = {
|
||||
fromDate,
|
||||
toDate,
|
||||
organizers: args.organizer,
|
||||
participants: args.participant,
|
||||
channelId: args.channel,
|
||||
hostEmail: args.host,
|
||||
mine: args.mine,
|
||||
limit: args.limit,
|
||||
pageSize: args.pageSize,
|
||||
maxRecords: args.maxRecords,
|
||||
};
|
||||
|
||||
const summaryConfig = getSummaryFetchConfig();
|
||||
const plan = getFirefliesPlan();
|
||||
const autoCreateContacts = shouldAutoCreateContacts();
|
||||
|
||||
logger.info(
|
||||
`Starting historical import (dryRun=${Boolean(args.dryRun)}, plan=${plan}, pageSize=${filters.pageSize ?? 50})`,
|
||||
);
|
||||
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
|
||||
const importer = new HistoricalImporter(firefliesClient, twentyService);
|
||||
|
||||
const result = await importer.run(filters, {
|
||||
dryRun: args.dryRun,
|
||||
autoCreateContacts,
|
||||
summaryConfig,
|
||||
plan,
|
||||
});
|
||||
|
||||
console.log('✅ Historical import summary:');
|
||||
const summary = {
|
||||
dryRun: result.dryRun,
|
||||
totalListed: result.totalListed,
|
||||
imported: result.imported,
|
||||
skippedExisting: result.skippedExisting,
|
||||
summaryPending: result.summaryPending,
|
||||
failed: result.failed,
|
||||
};
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
||||
if (result.statuses.length > 0) {
|
||||
console.log('Status by meeting:');
|
||||
console.table(
|
||||
result.statuses.map((s) => ({
|
||||
meetingId: s.meetingId,
|
||||
title: s.title ?? '',
|
||||
status: s.status,
|
||||
reason: s.reason ?? '',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (result.failed.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to import historical meetings');
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
if (error.stack) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
} else {
|
||||
console.error(String(error));
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Fetch a Fireflies meeting by ID and insert it into Twenty using the same path
|
||||
* as the webhook handler.
|
||||
*
|
||||
* Usage:
|
||||
* yarn meeting:ingest <meetingId>
|
||||
* Or
|
||||
* MEETING_ID=... yarn meeting:ingest
|
||||
*
|
||||
* Required env:
|
||||
* FIREFLIES_API_KEY
|
||||
* FIREFLIES_WEBHOOK_SECRET
|
||||
* TWENTY_API_KEY
|
||||
*
|
||||
* Optional env:
|
||||
* SERVER_URL (defaults to http://localhost:3000)
|
||||
* FIREFLIES_PLAN (free|pro|business|enterprise)
|
||||
*/
|
||||
|
||||
import { createHmac } from 'crypto';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { WebhookHandler } from '../src/webhook-handler';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const meetingId = args[0] || process.env.MEETING_ID;
|
||||
|
||||
if (!meetingId) {
|
||||
console.error('❌ meetingId is required (arg or MEETING_ID env)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
console.error('❌ FIREFLIES_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!twentyApiKey) {
|
||||
console.error('❌ TWENTY_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!webhookSecret) {
|
||||
console.error('❌ FIREFLIES_WEBHOOK_SECRET is required to generate signature');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
meetingId,
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = `sha256=${createHmac('sha256', webhookSecret)
|
||||
.update(body, 'utf8')
|
||||
.digest('hex')}`;
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
console.log(`🚀 Ingesting meeting ${meetingId} via webhook handler`);
|
||||
const handler = new WebhookHandler();
|
||||
const result = await handler.handle(payload, {
|
||||
'x-hub-signature': signature,
|
||||
body,
|
||||
});
|
||||
|
||||
console.log('✅ Result:');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to ingest meeting');
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -40,13 +40,11 @@ const FIREFLIES_WEBHOOK_SECRET = process.env.FIREFLIES_WEBHOOK_SECRET || 'test_s
|
||||
const _FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY || 'test_api_key';
|
||||
|
||||
// Test meeting data (simulating Fireflies API response)
|
||||
const TEST_MEETING_ID = process.env.MEETING_ID || 'test-meeting-local-' + Date.now();
|
||||
const CLIENT_REFERENCE_ID = process.env.CLIENT_REFERENCE_ID;
|
||||
|
||||
const TEST_MEETING_ID = 'test-meeting-local-' + Date.now();
|
||||
const TEST_WEBHOOK_PAYLOAD = {
|
||||
meetingId: TEST_MEETING_ID,
|
||||
eventType: 'Transcription completed',
|
||||
...(CLIENT_REFERENCE_ID ? { clientReferenceId: CLIENT_REFERENCE_ID } : {}),
|
||||
clientReferenceId: 'test-client-ref',
|
||||
};
|
||||
|
||||
// Mock Fireflies GraphQL API response
|
||||
@@ -121,17 +119,11 @@ const main = async () => {
|
||||
}
|
||||
|
||||
// Prepare webhook payload
|
||||
const unsignedBody = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
|
||||
const signature = generateHMACSignature(unsignedBody, FIREFLIES_WEBHOOK_SECRET);
|
||||
const payloadWithSignature = {
|
||||
...TEST_WEBHOOK_PAYLOAD,
|
||||
'x-hub-signature': signature,
|
||||
};
|
||||
const body = JSON.stringify(payloadWithSignature);
|
||||
const body = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
|
||||
const signature = generateHMACSignature(body, FIREFLIES_WEBHOOK_SECRET);
|
||||
|
||||
console.log('📤 Sending webhook payload:');
|
||||
console.log(JSON.stringify(payloadWithSignature, null, 2));
|
||||
console.log('\nℹ️ Signature is sent both as header (preferred) and in payload as fallback (headers are not passed to serverless functions)\n');
|
||||
console.log(JSON.stringify(TEST_WEBHOOK_PAYLOAD, null, 2));
|
||||
console.log(`\n🔐 HMAC Signature: ${signature}\n`);
|
||||
|
||||
// Check if server is reachable
|
||||
@@ -186,31 +178,6 @@ const main = async () => {
|
||||
console.log('📥 Response Body:');
|
||||
console.log(JSON.stringify(responseData, null, 2));
|
||||
|
||||
// Report whether the server appears to have received the header signature
|
||||
const debugMessages = Array.isArray((responseData as any)?.debug)
|
||||
? ((responseData as any).debug as string[])
|
||||
: [];
|
||||
const headerMissing =
|
||||
debugMessages.some((msg) => msg.includes('headerKeys=none')) ||
|
||||
debugMessages.some((msg) => msg.includes('providedSignature=undefined'));
|
||||
const signatureErrors =
|
||||
Array.isArray((responseData as any)?.errors) &&
|
||||
((responseData as any).errors as unknown[]).some(
|
||||
(err) => typeof err === 'string' && err.toLowerCase().includes('signature'),
|
||||
);
|
||||
|
||||
if (headerMissing) {
|
||||
console.log(
|
||||
'\n⚠️ Server did not report any received headers; it may be using payload fallback for signature verification.',
|
||||
);
|
||||
} else {
|
||||
console.log('\n✅ Server reported headers present (header-based signature should be used).');
|
||||
}
|
||||
|
||||
if (signatureErrors) {
|
||||
console.log('⚠️ Signature was rejected by the server (check webhook secret / payload).');
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
console.log('\n✅ Webhook test completed successfully!');
|
||||
console.log('\n📋 Next steps:');
|
||||
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
import { createLogger } from './logger';
|
||||
import type { FirefliesMeetingData, FirefliesParticipant, SummaryFetchConfig } from './types';
|
||||
|
||||
const logger = createLogger('fireflies-api');
|
||||
|
||||
export class FirefliesApiClient {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY is required');
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async 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') {
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
logger.debug(`summary ready: ${ready}`);
|
||||
return { data: meetingData, summaryReady: ready };
|
||||
}
|
||||
|
||||
// immediate_with_retry: retry with exponential backoff
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
|
||||
|
||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||
try {
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
|
||||
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
|
||||
|
||||
if (ready) {
|
||||
return { data: meetingData, summaryReady: true };
|
||||
}
|
||||
|
||||
if (attempt < config.retryAttempts) {
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
} else {
|
||||
logger.debug(`max retries reached, returning partial data`);
|
||||
return { data: meetingData, summaryReady: false };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
|
||||
|
||||
if (attempt === config.retryAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`retrying in ${delayMs}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch meeting data after retries');
|
||||
}
|
||||
|
||||
private 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[] = [];
|
||||
|
||||
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
|
||||
logger.debug('participants field:', JSON.stringify(transcript.participants));
|
||||
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
|
||||
logger.debug('speakers field:', transcript.speakers?.map((s: any) => s.name));
|
||||
logger.debug('meeting_attendance field:', transcript.meeting_attendance?.map((a: any) => a.name));
|
||||
logger.debug('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] : '';
|
||||
// Extract name properly: if there's an email in angle brackets, get the part before it
|
||||
const name = emailMatch
|
||||
? part.substring(0, part.indexOf('<')).trim()
|
||||
: part.trim();
|
||||
|
||||
// Skip if the "name" is actually an email address
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping participant with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if empty name
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip duplicates
|
||||
if (isDuplicate(name, email)) {
|
||||
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else if (name) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extract from meeting_attendees field (structured)
|
||||
if (transcript.meeting_attendees && Array.isArray(transcript.meeting_attendees)) {
|
||||
transcript.meeting_attendees.forEach((attendee: any) => {
|
||||
const name = attendee.displayName || attendee.name || '';
|
||||
const email = attendee.email || '';
|
||||
|
||||
// Skip if name is actually an email
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping attendee with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, email)) {
|
||||
if (email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Extract from speakers field (name only)
|
||||
if (transcript.speakers && Array.isArray(transcript.speakers)) {
|
||||
transcript.speakers.forEach((speaker: any) => {
|
||||
const name = speaker.name || '';
|
||||
|
||||
// Skip if name is actually an email
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping speaker with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Extract from meeting_attendance field (name only)
|
||||
if (transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
|
||||
transcript.meeting_attendance.forEach((attendance: any) => {
|
||||
const name = attendance.name || '';
|
||||
|
||||
// Skip if name is actually an email or contains comma-separated emails
|
||||
if (isEmail(name) || name.includes(',')) {
|
||||
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Add organizer email if available and not already included
|
||||
const organizerEmail = transcript.organizer_email;
|
||||
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];
|
||||
|
||||
logger.debug('=== EXTRACTED PARTICIPANTS ===');
|
||||
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
|
||||
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
|
||||
logger.debug('Total:', allParticipants.length);
|
||||
|
||||
return allParticipants;
|
||||
}
|
||||
|
||||
private transformMeetingData(transcript: 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
// Serverless function entry point - re-exports from src/lib
|
||||
export { config, main } from '../../../src';
|
||||
export { config, main } from './receive-fireflies-notes';
|
||||
export type {
|
||||
FirefliesMeetingData,
|
||||
FirefliesParticipant,
|
||||
@@ -7,5 +6,5 @@ export type {
|
||||
ProcessResult,
|
||||
SummaryFetchConfig,
|
||||
SummaryStrategy
|
||||
} from '../../../src';
|
||||
} from './types';
|
||||
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
||||
|
||||
export interface LoggerConfig {
|
||||
logLevel: LogLevel;
|
||||
isTestEnvironment: boolean;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
silent: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* App-level Fireflies application logger with configurable log levels.
|
||||
*/
|
||||
export class AppLogger {
|
||||
private config: LoggerConfig;
|
||||
private context: string;
|
||||
|
||||
constructor(context: string) {
|
||||
this.context = context;
|
||||
this.config = {
|
||||
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
|
||||
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private parseLogLevel(level: string): LogLevel {
|
||||
const normalizedLevel = level.toLowerCase() as LogLevel;
|
||||
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug information (LOG_LEVEL=debug)
|
||||
*/
|
||||
debug(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log informational messages (LOG_LEVEL=info or lower)
|
||||
*/
|
||||
info(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('info')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warnings (LOG_LEVEL=warn or lower)
|
||||
*/
|
||||
warn(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('warn')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log errors (LOG_LEVEL=error or lower)
|
||||
*/
|
||||
error(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('error')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log critical errors that should ALWAYS be visible regardless of log level
|
||||
* Use sparingly - only for fatal errors, security issues, or data corruption
|
||||
*/
|
||||
critical(message: string, ...args: any[]): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create loggers with automatic context detection
|
||||
*/
|
||||
export const createLogger = (context: string): AppLogger => {
|
||||
return new AppLogger(context);
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
+67
-74
@@ -43,20 +43,6 @@ export class TwentyCrmService {
|
||||
return response.data?.meetings?.edges?.[0]?.node;
|
||||
}
|
||||
|
||||
async findMeetingByFirefliesId(meetingId: string): Promise<IdNode | undefined> {
|
||||
const query = `
|
||||
query FindMeetingByFirefliesId($meetingId: String!) {
|
||||
meetings(filter: { firefliesMeetingId: { eq: $meetingId } }) {
|
||||
edges { node { id } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = { meetingId };
|
||||
const response = await this.gqlRequest<FindMeetingResponse>(query, variables);
|
||||
return response.data?.meetings?.edges?.[0]?.node;
|
||||
}
|
||||
|
||||
async matchParticipantsToContacts(
|
||||
participants: FirefliesParticipant[],
|
||||
): Promise<{
|
||||
@@ -67,18 +53,21 @@ export class TwentyCrmService {
|
||||
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);
|
||||
@@ -137,6 +126,7 @@ export class TwentyCrmService {
|
||||
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) {
|
||||
@@ -162,37 +152,27 @@ export class TwentyCrmService {
|
||||
const firstName = nameParts[0];
|
||||
const lastName = nameParts.slice(1).join(' ');
|
||||
|
||||
const hasLastName = Boolean(lastName);
|
||||
|
||||
const query = hasLastName
|
||||
? `
|
||||
query FindPeopleByName($firstName: String!, $lastName: String!) {
|
||||
people(filter: {
|
||||
and: [
|
||||
{ name: { firstName: { eq: $firstName } } }
|
||||
{ name: { lastName: { eq: $lastName } } }
|
||||
]
|
||||
}) {
|
||||
edges { node { id emails { primaryEmail } name { firstName lastName } } }
|
||||
}
|
||||
// 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 } } }
|
||||
}
|
||||
`
|
||||
: `
|
||||
query FindPeopleByName($firstName: String!) {
|
||||
people(filter: {
|
||||
name: { firstName: { eq: $firstName } }
|
||||
}) {
|
||||
edges { node { id emails { primaryEmail } name { firstName lastName } } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
`;
|
||||
|
||||
const variables: Record<string, unknown> = hasLastName
|
||||
? { firstName, lastName }
|
||||
: { firstName };
|
||||
let variables: any = { firstName };
|
||||
if (lastName) {
|
||||
variables.lastName = lastName;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(query, variables);
|
||||
const response = await this.gqlRequest<any>(query, variables);
|
||||
const people = response.data?.people?.edges;
|
||||
|
||||
if (people && people.length > 0) {
|
||||
@@ -203,8 +183,9 @@ export class TwentyCrmService {
|
||||
};
|
||||
}
|
||||
|
||||
if (hasLastName) {
|
||||
const fuzzyQuery = `
|
||||
// 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 } } }
|
||||
@@ -212,11 +193,12 @@ export class TwentyCrmService {
|
||||
}
|
||||
`;
|
||||
|
||||
const fuzzyResponse = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(fuzzyQuery, { firstName: `%${firstName}%` });
|
||||
const fuzzyResponse = await this.gqlRequest<any>(query, { firstName: `%${firstName}%` });
|
||||
const fuzzyPeople = fuzzyResponse.data?.people?.edges;
|
||||
|
||||
if (fuzzyPeople && fuzzyPeople.length > 0) {
|
||||
const bestMatch = fuzzyPeople.find((edge) => {
|
||||
// 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());
|
||||
});
|
||||
@@ -233,6 +215,7 @@ export class TwentyCrmService {
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
// Silently fail - don't break the entire process for a single contact lookup
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -242,14 +225,17 @@ export class TwentyCrmService {
|
||||
): 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
|
||||
if (participantsWithEmails.length > 0) {
|
||||
const emailContactIds = await this.createContactsWithEmails(participantsWithEmails);
|
||||
newContactIds.push(...emailContactIds);
|
||||
}
|
||||
|
||||
// Process participants with names only
|
||||
if (participantsNameOnly.length > 0) {
|
||||
const nameContactIds = await this.createContactsNameOnly(participantsNameOnly);
|
||||
newContactIds.push(...nameContactIds);
|
||||
@@ -261,6 +247,7 @@ export class TwentyCrmService {
|
||||
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) {
|
||||
@@ -289,10 +276,7 @@ export class TwentyCrmService {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables, {
|
||||
suppressErrorCodes: ['BAD_USER_INPUT'],
|
||||
suppressErrorMessageIncludes: ['Duplicate Emails', 'duplicate entry'],
|
||||
});
|
||||
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables);
|
||||
if (!response.data?.createPerson?.id) {
|
||||
throw new Error(`Failed to create contact for ${participant.email}`);
|
||||
}
|
||||
@@ -313,6 +297,7 @@ export class TwentyCrmService {
|
||||
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()
|
||||
@@ -326,6 +311,7 @@ export class TwentyCrmService {
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
logger.warn(`Contact with name "${participant.name}" already exists. Skipping creation.`);
|
||||
@@ -344,6 +330,8 @@ export class TwentyCrmService {
|
||||
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
|
||||
},
|
||||
};
|
||||
|
||||
@@ -358,6 +346,7 @@ export class TwentyCrmService {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.warn(`Failed to create contact for ${participant.name}: ${errorMessage}`);
|
||||
// Continue processing other participants instead of failing completely
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -420,7 +409,28 @@ export class TwentyCrmService {
|
||||
},
|
||||
};
|
||||
|
||||
await this.gqlRequest<{ createNoteTarget: { id: string; noteId: string; personId: string } }>(mutation, variables);
|
||||
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> {
|
||||
@@ -432,6 +442,7 @@ export class TwentyCrmService {
|
||||
|
||||
const variables = { data: meetingData };
|
||||
|
||||
// Debug: log the variables being sent
|
||||
if (!this.isTestEnvironment) {
|
||||
logger.debug('createMeeting variables:', JSON.stringify(variables, null, 2));
|
||||
}
|
||||
@@ -445,8 +456,7 @@ export class TwentyCrmService {
|
||||
|
||||
private async gqlRequest<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
options?: { suppressErrorCodes?: string[]; suppressErrorMessageIncludes?: string[] }
|
||||
variables?: Record<string, unknown>
|
||||
): Promise<GraphQLResponse<T>> {
|
||||
const url = `${this.apiUrl}/graphql`;
|
||||
|
||||
@@ -490,16 +500,7 @@ export class TwentyCrmService {
|
||||
|
||||
return json;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const suppressByCode = options?.suppressErrorCodes?.some((code) =>
|
||||
message.includes(code),
|
||||
);
|
||||
const suppressByMessage = options?.suppressErrorMessageIncludes?.some((substring) =>
|
||||
message.includes(substring),
|
||||
);
|
||||
if (!suppressByCode && !suppressByMessage) {
|
||||
logger.error('GraphQL request error:', error);
|
||||
}
|
||||
logger.error('GraphQL request error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -524,15 +525,7 @@ export class TwentyCrmService {
|
||||
return response.data.createMeeting.id;
|
||||
}
|
||||
|
||||
async findFailedMeetings(): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
firefliesMeetingId: string;
|
||||
importError: string;
|
||||
lastImportAttempt: string;
|
||||
importAttempts: number;
|
||||
createdAt: string;
|
||||
}>> {
|
||||
async findFailedMeetings(): Promise<any[]> {
|
||||
const query = `
|
||||
query FindFailedMeetings {
|
||||
meetings(filter: { importStatus: { eq: "FAILED" } }) {
|
||||
@@ -551,8 +544,8 @@ export class TwentyCrmService {
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await this.gqlRequest<{ meetings: { edges: Array<{ node: { id: string; name: string; firefliesMeetingId: string; importError: string; lastImportAttempt: string; importAttempts: number; createdAt: string } }> } }>(query);
|
||||
return response.data?.meetings?.edges?.map((edge) => edge.node) || [];
|
||||
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> {
|
||||
@@ -571,7 +564,7 @@ export class TwentyCrmService {
|
||||
}
|
||||
};
|
||||
|
||||
await this.gqlRequest<{ updateMeeting: { id: string } }>(mutation, variables);
|
||||
await this.gqlRequest<any>(mutation, variables);
|
||||
}
|
||||
}
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// 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;
|
||||
};
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
};
|
||||
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
import { FirefliesApiClient } from './fireflies-api-client';
|
||||
import { MeetingFormatter } from './formatters';
|
||||
import { createLogger } from './logger';
|
||||
import { TwentyCrmService } from './twenty-crm-service';
|
||||
import type { FirefliesWebhookPayload, ProcessResult } from './types';
|
||||
import { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
|
||||
import {
|
||||
getWebhookSecretFingerprint,
|
||||
isValidFirefliesPayload,
|
||||
verifyWebhookSignature
|
||||
} from './webhook-validator';
|
||||
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
const logger = createLogger('fireflies');
|
||||
|
||||
export class WebhookHandler {
|
||||
private debug: string[] = [];
|
||||
private isTestEnvironment: boolean;
|
||||
|
||||
constructor() {
|
||||
this.isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
|
||||
}
|
||||
|
||||
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
|
||||
const result: ProcessResult = {
|
||||
success: false,
|
||||
noteIds: [],
|
||||
newContacts: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
logger.debug('invoked');
|
||||
logger.debug(`apiUrl=${getApiUrl()}`);
|
||||
|
||||
// 0) Validate environment configuration
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY environment variable is required');
|
||||
}
|
||||
if (!twentyApiKey) {
|
||||
logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('TWENTY_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
// 1) Parse and validate webhook payload and extract headers if wrapped together
|
||||
const { payload, extractedHeaders } = this.parsePayload(params);
|
||||
const finalHeaders = extractedHeaders || headers;
|
||||
|
||||
logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
|
||||
|
||||
// 2) Verify webhook signature
|
||||
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
|
||||
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
|
||||
logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
|
||||
|
||||
this.verifySignature(payload, finalHeaders, webhookSecret);
|
||||
logger.debug('signature verification: ok');
|
||||
|
||||
// 3) Fetch meeting data from Fireflies
|
||||
const summaryConfig = getSummaryFetchConfig();
|
||||
logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
|
||||
logger.debug(`fetching meeting data from Fireflies API`);
|
||||
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
|
||||
payload.meetingId,
|
||||
summaryConfig
|
||||
);
|
||||
|
||||
logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
|
||||
|
||||
result.summaryReady = summaryReady;
|
||||
result.summaryPending = !summaryReady;
|
||||
|
||||
// Extract business intelligence
|
||||
if (summaryReady) {
|
||||
result.actionItemsCount = meetingData.summary.action_items.length;
|
||||
result.keyTopics = meetingData.summary.topics_discussed;
|
||||
result.meetingType = meetingData.summary.meeting_type;
|
||||
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
result.sentimentScore = sentiments.positive_pct / 100;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Check for duplicate meetings
|
||||
const twentyService = new TwentyCrmService(
|
||||
twentyApiKey,
|
||||
getApiUrl()
|
||||
);
|
||||
|
||||
const existingMeeting = await twentyService.findExistingMeeting(meetingData.title);
|
||||
if (existingMeeting) {
|
||||
logger.debug(`meeting already exists id=${existingMeeting.id}`);
|
||||
result.success = true;
|
||||
result.meetingId = existingMeeting.id;
|
||||
result.debug = this.debug;
|
||||
return result;
|
||||
}
|
||||
logger.debug('no existing meeting found, proceeding');
|
||||
|
||||
// 5) Match participants to existing contacts
|
||||
logger.debug(`total participants from API: ${meetingData.participants.length}`);
|
||||
meetingData.participants.forEach((p, idx) => {
|
||||
logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
|
||||
meetingData.participants
|
||||
);
|
||||
logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
|
||||
|
||||
unmatchedParticipants.forEach((p, idx) => {
|
||||
logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
// 6) Optionally create contacts
|
||||
const autoCreate = shouldAutoCreateContacts();
|
||||
const newContactIds = autoCreate
|
||||
? await twentyService.createContactsForUnmatched(unmatchedParticipants)
|
||||
: [];
|
||||
result.newContacts = newContactIds;
|
||||
logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
|
||||
|
||||
// 7) Create note first (so we can link to it from the meeting)
|
||||
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
|
||||
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
|
||||
const noteId = await twentyService.createNoteOnly(
|
||||
`Meeting: ${meetingData.title}`,
|
||||
noteBody
|
||||
);
|
||||
result.noteIds = [noteId];
|
||||
logger.debug(`created note id=${noteId}`);
|
||||
|
||||
// 8) Create meeting with direct relationship to the note
|
||||
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
|
||||
logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
|
||||
result.meetingId = await twentyService.createMeeting(meetingInput);
|
||||
logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
|
||||
|
||||
// 9) Link note to participants (Meeting link is handled via the relation field)
|
||||
await this.linkNoteToParticipants(
|
||||
twentyService,
|
||||
noteId,
|
||||
allContactIds
|
||||
);
|
||||
logger.debug(`linked note to ${allContactIds.length} participants`);
|
||||
|
||||
result.success = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`error: ${message}`);
|
||||
result.errors?.push(message);
|
||||
|
||||
// Try to create a failed meeting record for tracking
|
||||
await this.createFailedMeetingRecord(params, message);
|
||||
}
|
||||
|
||||
result.debug = this.debug;
|
||||
return result;
|
||||
}
|
||||
|
||||
private parsePayload(params: unknown): { payload: FirefliesWebhookPayload; extractedHeaders?: Record<string, string> } {
|
||||
let normalizedParams = params;
|
||||
let extractedHeaders: Record<string, string> | undefined;
|
||||
|
||||
// Handle string-encoded params
|
||||
if (typeof normalizedParams === 'string') {
|
||||
logger.debug(`received params as string length=${normalizedParams.length}`);
|
||||
try {
|
||||
const parsed = JSON.parse(normalizedParams);
|
||||
normalizedParams = parsed;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsedKeys = Object.keys(parsed as Record<string, unknown>);
|
||||
logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.error(`error parsing string params: ${String(parseError)}`);
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle wrapped payloads and extract headers if present
|
||||
let payload: FirefliesWebhookPayload | undefined;
|
||||
if (isValidFirefliesPayload(normalizedParams)) {
|
||||
payload = normalizedParams as FirefliesWebhookPayload;
|
||||
} else if (normalizedParams && typeof normalizedParams === 'object') {
|
||||
const wrapper = normalizedParams as Record<string, unknown>;
|
||||
|
||||
// Extract headers if present in wrapper
|
||||
if (wrapper.headers && typeof wrapper.headers === 'object' && !Array.isArray(wrapper.headers)) {
|
||||
extractedHeaders = wrapper.headers as Record<string, string>;
|
||||
const headerKeys = Object.keys(extractedHeaders);
|
||||
logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
|
||||
}
|
||||
|
||||
const wrapperKeys = ['params', 'payload', 'body', 'data', 'event'];
|
||||
for (const key of wrapperKeys) {
|
||||
const candidate = wrapper[key];
|
||||
if (isValidFirefliesPayload(candidate)) {
|
||||
logger.debug(`detected payload under wrapper key "${key}"`);
|
||||
payload = candidate as FirefliesWebhookPayload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
logger.error('error: Invalid or missing webhook payload');
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
|
||||
// Log payload keys for debugging
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadKeys = Object.keys(payloadRecord);
|
||||
if (payloadKeys.length > 0) {
|
||||
logger.debug(`payload keys: ${payloadKeys.join(',')}`);
|
||||
}
|
||||
|
||||
return { payload, extractedHeaders };
|
||||
}
|
||||
|
||||
private verifySignature(
|
||||
payload: FirefliesWebhookPayload,
|
||||
headers: Record<string, string> | undefined,
|
||||
webhookSecret: string
|
||||
): void {
|
||||
// Extract headers
|
||||
const normalizedHeaders = headers || {};
|
||||
const headerKeys = Object.keys(normalizedHeaders);
|
||||
if (headerKeys.length > 0) {
|
||||
logger.debug(`header keys: ${headerKeys.join(',')}`);
|
||||
}
|
||||
|
||||
const headerSignature = Object.entries(normalizedHeaders).find(
|
||||
([key]) => key.toLowerCase() === 'x-hub-signature',
|
||||
)?.[1];
|
||||
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadSignature =
|
||||
typeof payloadRecord['x-hub-signature'] === 'string'
|
||||
? (payloadRecord['x-hub-signature'] as string)
|
||||
: undefined;
|
||||
|
||||
if (payloadSignature) {
|
||||
logger.debug('found signature inside payload');
|
||||
}
|
||||
|
||||
const signature =
|
||||
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
|
||||
|
||||
const body = typeof normalizedHeaders['body'] === 'string'
|
||||
? normalizedHeaders['body']
|
||||
: JSON.stringify(payloadRecord);
|
||||
|
||||
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
|
||||
if (!signatureCheck.isValid) {
|
||||
logger.debug(
|
||||
`signature check failed. headerPresent=${Boolean(
|
||||
headerSignature,
|
||||
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
|
||||
);
|
||||
if (signature) {
|
||||
logger.debug(`provided signature=${signature}`);
|
||||
} else {
|
||||
logger.debug('provided signature=undefined');
|
||||
}
|
||||
logger.debug(
|
||||
`computed signature=${signatureCheck.computedSignature ?? 'unavailable'}`,
|
||||
);
|
||||
logger.critical('Invalid webhook signature - potential security threat detected in production');
|
||||
throw new Error('Invalid webhook signature');
|
||||
}
|
||||
}
|
||||
|
||||
private async linkNoteToParticipants(
|
||||
twentyService: TwentyCrmService,
|
||||
noteId: string,
|
||||
contactIds: string[]
|
||||
): Promise<void> {
|
||||
// Create Note-Person links for each participant
|
||||
for (const contactId of contactIds) {
|
||||
try {
|
||||
await twentyService.createNoteTarget(noteId, contactId);
|
||||
logger.debug(`linked note ${noteId} to person ${contactId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`failed to link note to person ${contactId}: ${message}`);
|
||||
// Continue with other participants
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
|
||||
try {
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
if (!twentyApiKey) {
|
||||
logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to extract meeting ID and title from the params
|
||||
let meetingId = 'unknown';
|
||||
let meetingTitle = 'Unknown Meeting';
|
||||
|
||||
const { payload } = this.parsePayload(params);
|
||||
if (payload?.meetingId) {
|
||||
meetingId = payload.meetingId;
|
||||
|
||||
// Try to get meeting title from Fireflies API if possible
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
if (firefliesApiKey) {
|
||||
try {
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
|
||||
meetingTitle = meetingData.title || meetingTitle;
|
||||
} catch (fetchError) {
|
||||
logger.debug(`Could not fetch meeting title: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
|
||||
const failedMeetingData = MeetingFormatter.toFailedMeetingCreateInput(
|
||||
meetingId,
|
||||
meetingTitle,
|
||||
error
|
||||
);
|
||||
|
||||
const failedMeetingId = await twentyService.createFailedMeeting(failedMeetingData);
|
||||
logger.debug(`Created failed meeting record: ${failedMeetingId}`);
|
||||
} catch (recordError) {
|
||||
// Don't throw here - we don't want to break the original error handling
|
||||
logger.error(`Failed to create failed meeting record: ${recordError instanceof Error ? recordError.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
main,
|
||||
type FirefliesMeetingData,
|
||||
type FirefliesWebhookPayload,
|
||||
} from '../';
|
||||
} from '../actions/receive-fireflies-notes';
|
||||
|
||||
// Helper to generate HMAC signature
|
||||
const generateHMACSignature = (body: string, secret: string): string => {
|
||||
@@ -39,12 +39,10 @@ const mockFirefliesApiResponseWithSummary = {
|
||||
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,
|
||||
},
|
||||
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',
|
||||
@@ -83,7 +81,7 @@ const mockMeetingWithFullSummary: FirefliesMeetingData = {
|
||||
},
|
||||
},
|
||||
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
|
||||
video_url: 'https://app.fireflies.ai/recording/test-001',
|
||||
recording_url: 'https://app.fireflies.ai/recording/test-001',
|
||||
summary_status: 'completed',
|
||||
};
|
||||
|
||||
@@ -406,11 +404,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summaryReady).toBe(true);
|
||||
expect(result.actionItemsCount).toBe(3);
|
||||
expect(result.sentimentAnalysis).toEqual({
|
||||
positive_pct: 75,
|
||||
negative_pct: 10,
|
||||
neutral_pct: 15,
|
||||
});
|
||||
expect(result.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']);
|
||||
});
|
||||
@@ -571,7 +565,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
|
||||
const createNoteMock = jest.fn();
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: any) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -582,9 +576,9 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
}
|
||||
|
||||
// Twenty API
|
||||
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
|
||||
if (requestBody.query?.includes('createNote')) {
|
||||
createNoteMock(requestBody.variables);
|
||||
const body = options?.body ? JSON.parse(options.body) : {};
|
||||
if (body.query?.includes('createNote')) {
|
||||
createNoteMock(body.variables);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
@@ -616,7 +610,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
expect(noteData.data.bodyV2.markdown).toContain('## Overview'); // Markdown header, not bold
|
||||
expect(noteData.data.bodyV2.markdown).toContain('## Action Items'); // Markdown header, not bold
|
||||
expect(noteData.data.bodyV2.markdown).toContain('**Sentiment:**'); // This is bold
|
||||
expect(noteData.data.bodyV2.markdown).toContain('View Full Transcript on Fireflies');
|
||||
expect(noteData.data.bodyV2.markdown).toContain('[View Full Transcript]');
|
||||
});
|
||||
|
||||
it('should create meeting records for multi-party meetings', async () => {
|
||||
@@ -630,7 +624,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
|
||||
const createMeetingMock = jest.fn();
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: any) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -641,9 +635,9 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
}
|
||||
|
||||
// Twenty API
|
||||
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
|
||||
if (requestBody.query?.includes('createMeeting')) {
|
||||
createMeetingMock(requestBody.variables);
|
||||
const body = options?.body ? JSON.parse(options.body) : {};
|
||||
if (body.query?.includes('createMeeting')) {
|
||||
createMeetingMock(body.variables);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
@@ -651,7 +645,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (requestBody.query?.includes('createNote')) {
|
||||
if (body.query?.includes('createNote')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
@@ -700,7 +694,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
});
|
||||
|
||||
it('should handle missing payload gracefully', async () => {
|
||||
const result = await main(null as unknown, {});
|
||||
const result = await main(null as any, {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
@@ -709,10 +703,10 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
it('should handle invalid payload structure', async () => {
|
||||
const invalidPayload = { invalid: 'data' };
|
||||
|
||||
const result = await main(invalidPayload as unknown, {});
|
||||
const result = await main(invalidPayload as any, {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { HistoricalImporter } from '../historical-importer';
|
||||
import type { FirefliesMeetingData, SummaryFetchConfig } from '../types';
|
||||
|
||||
const summaryConfig: SummaryFetchConfig = {
|
||||
strategy: 'immediate_with_retry',
|
||||
retryAttempts: 1,
|
||||
retryDelay: 0,
|
||||
pollInterval: 0,
|
||||
maxPolls: 0,
|
||||
};
|
||||
|
||||
const sampleMeeting: FirefliesMeetingData = {
|
||||
id: 'm-1',
|
||||
title: 'Sample',
|
||||
date: new Date().toISOString(),
|
||||
duration: 30,
|
||||
participants: [],
|
||||
summary: { action_items: [], overview: '' },
|
||||
transcript_url: 'https://example.com',
|
||||
};
|
||||
|
||||
describe('HistoricalImporter', () => {
|
||||
const buildImporter = () => {
|
||||
const firefliesClient = {
|
||||
listTranscripts: jest.fn(),
|
||||
fetchMeetingDataWithRetry: jest.fn(),
|
||||
} as unknown as jest.Mocked<any>;
|
||||
|
||||
const twentyService = {
|
||||
findMeetingByFirefliesId: jest.fn(),
|
||||
matchParticipantsToContacts: jest.fn(),
|
||||
createContactsForUnmatched: jest.fn(),
|
||||
createNoteOnly: jest.fn(),
|
||||
createMeeting: jest.fn(),
|
||||
createNoteTarget: jest.fn(),
|
||||
} as unknown as jest.Mocked<any>;
|
||||
|
||||
return { firefliesClient, twentyService };
|
||||
};
|
||||
|
||||
it('skips meetings that already exist by firefliesMeetingId', async () => {
|
||||
const { firefliesClient, twentyService } = buildImporter();
|
||||
|
||||
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'existing' }]);
|
||||
twentyService.findMeetingByFirefliesId.mockResolvedValue({ id: 'twenty-id' });
|
||||
|
||||
const importer = new HistoricalImporter(firefliesClient, twentyService);
|
||||
const result = await importer.run(
|
||||
{},
|
||||
{ dryRun: false, autoCreateContacts: true, summaryConfig, plan: 'free' },
|
||||
);
|
||||
|
||||
expect(result.skippedExisting).toBe(1);
|
||||
expect(result.imported).toBe(0);
|
||||
expect(twentyService.createMeeting).not.toHaveBeenCalled();
|
||||
expect(result.statuses[0].status).toBe('skipped_existing');
|
||||
});
|
||||
|
||||
it('supports dry-run without writing to Twenty', async () => {
|
||||
const { firefliesClient, twentyService } = buildImporter();
|
||||
|
||||
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'm-2' }]);
|
||||
firefliesClient.fetchMeetingDataWithRetry.mockResolvedValue({
|
||||
data: sampleMeeting,
|
||||
summaryReady: false,
|
||||
});
|
||||
twentyService.findMeetingByFirefliesId.mockResolvedValue(undefined);
|
||||
|
||||
const importer = new HistoricalImporter(firefliesClient, twentyService);
|
||||
const result = await importer.run(
|
||||
{},
|
||||
{ dryRun: true, autoCreateContacts: false, summaryConfig, plan: 'free' },
|
||||
);
|
||||
|
||||
expect(result.imported).toBe(1);
|
||||
expect(result.summaryPending).toBe(1);
|
||||
expect(twentyService.createMeeting).not.toHaveBeenCalled();
|
||||
expect(twentyService.createNoteOnly).not.toHaveBeenCalled();
|
||||
expect(result.statuses).toHaveLength(1);
|
||||
expect(result.statuses[0].status).toBe('pending_summary');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ process.env.AUTO_CREATE_CONTACTS = 'true';
|
||||
process.env.SERVER_URL = 'http://localhost:3000';
|
||||
process.env.TWENTY_API_KEY = 'test-api-key';
|
||||
process.env.LOG_LEVEL = 'silent';
|
||||
process.env.CAPTURE_LOGS = 'false';
|
||||
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
import { WebhookHandler } from '../webhook-handler';
|
||||
|
||||
describe('WebhookHandler log capture', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
FIREFLIES_WEBHOOK_SECRET: 'testsecret',
|
||||
FIREFLIES_API_KEY: '',
|
||||
TWENTY_API_KEY: '',
|
||||
CAPTURE_LOGS: 'false',
|
||||
LOG_LEVEL: 'silent',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('includes debug logs in response when CAPTURE_LOGS is true', async () => {
|
||||
process.env.CAPTURE_LOGS = 'true';
|
||||
|
||||
const handler = new WebhookHandler();
|
||||
const result = await handler.handle(null);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(Array.isArray(result.debug)).toBe(true);
|
||||
});
|
||||
|
||||
it('omits debug logs when CAPTURE_LOGS is false', async () => {
|
||||
process.env.CAPTURE_LOGS = 'false';
|
||||
|
||||
const handler = new WebhookHandler();
|
||||
const result = await handler.handle(null);
|
||||
|
||||
expect(result.debug).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
config,
|
||||
main,
|
||||
type FirefliesMeetingData,
|
||||
type FirefliesParticipant,
|
||||
type FirefliesWebhookPayload,
|
||||
type ProcessResult,
|
||||
type SummaryFetchConfig,
|
||||
type SummaryStrategy
|
||||
} from '../../serverlessFunctions/receive-fireflies-notes/src/index';
|
||||
|
||||
@@ -1,823 +0,0 @@
|
||||
import { createLogger } from './logger';
|
||||
import {
|
||||
FIREFLIES_PLANS,
|
||||
type FirefliesMeetingData,
|
||||
type FirefliesParticipant,
|
||||
type FirefliesPlan,
|
||||
type FirefliesTranscriptListItem,
|
||||
type FirefliesTranscriptListOptions,
|
||||
type SummaryFetchConfig
|
||||
} from './types';
|
||||
|
||||
const logger = createLogger('fireflies-api');
|
||||
|
||||
export class FirefliesApiClient {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY is required');
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async listTranscripts(options: FirefliesTranscriptListOptions = {}): Promise<FirefliesTranscriptListItem[]> {
|
||||
const {
|
||||
organizers,
|
||||
participants,
|
||||
hostEmail,
|
||||
participantEmail,
|
||||
userId,
|
||||
channelId,
|
||||
mine,
|
||||
fromDate,
|
||||
toDate,
|
||||
pageSize = 50,
|
||||
maxRecords = 500,
|
||||
} = options;
|
||||
|
||||
const sanitizedOrganizers = organizers?.filter(Boolean);
|
||||
const sanitizedParticipants = participants?.filter(Boolean);
|
||||
|
||||
const transcripts: FirefliesTranscriptListItem[] = [];
|
||||
let skip = options.skip ?? 0;
|
||||
const limit = options.limit ?? pageSize;
|
||||
|
||||
const baseQuery = `
|
||||
query Transcripts(
|
||||
$limit: Int
|
||||
$skip: Int
|
||||
$hostEmail: String
|
||||
$participantEmail: String
|
||||
$organizers: [String!]
|
||||
$participants: [String!]
|
||||
$userId: String
|
||||
$channelId: String
|
||||
$mine: Boolean
|
||||
$date: Float
|
||||
) {
|
||||
transcripts(
|
||||
limit: $limit
|
||||
skip: $skip
|
||||
host_email: $hostEmail
|
||||
participant_email: $participantEmail
|
||||
organizers: $organizers
|
||||
participants: $participants
|
||||
user_id: $userId
|
||||
channel_id: $channelId
|
||||
mine: $mine
|
||||
date: $date
|
||||
) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
organizer_email
|
||||
participants
|
||||
transcript_url
|
||||
meeting_link
|
||||
meeting_info { summary_status }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
while (transcripts.length < maxRecords) {
|
||||
const pageVariables = {
|
||||
limit,
|
||||
skip,
|
||||
hostEmail,
|
||||
participantEmail,
|
||||
organizers: sanitizedOrganizers,
|
||||
participants: sanitizedParticipants,
|
||||
userId,
|
||||
channelId,
|
||||
mine,
|
||||
date: fromDate,
|
||||
};
|
||||
|
||||
const page = await this.executeTranscriptListQuery(baseQuery, pageVariables);
|
||||
const normalized = page
|
||||
.map((item) => {
|
||||
const normalizedDate = this.normalizeDate(item.date);
|
||||
return {
|
||||
id: (item.id as string) || '',
|
||||
title: (item.title as string) || 'Untitled Meeting',
|
||||
date: normalizedDate,
|
||||
duration: (item.duration as number) || 0,
|
||||
organizer_email: item.organizer_email as string | undefined,
|
||||
participants: Array.isArray(item.participants)
|
||||
? (item.participants as string[])
|
||||
: undefined,
|
||||
transcript_url: item.transcript_url as string | undefined,
|
||||
meeting_link: item.meeting_link as string | undefined,
|
||||
summary_status: (item.meeting_info as { summary_status?: string } | undefined)?.summary_status,
|
||||
};
|
||||
})
|
||||
.filter((item) => {
|
||||
if (toDate && item.date) {
|
||||
const itemTime = Date.parse(item.date);
|
||||
if (!Number.isNaN(itemTime) && itemTime > toDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
transcripts.push(...normalized);
|
||||
|
||||
if (page.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
skip += limit;
|
||||
}
|
||||
|
||||
if (transcripts.length > maxRecords) {
|
||||
return transcripts.slice(0, maxRecords);
|
||||
}
|
||||
|
||||
return transcripts;
|
||||
}
|
||||
|
||||
async fetchMeetingData(
|
||||
meetingId: string,
|
||||
options?: { timeout?: number; plan?: FirefliesPlan }
|
||||
): Promise<FirefliesMeetingData> {
|
||||
const plan = options?.plan ?? FIREFLIES_PLANS.FREE;
|
||||
const isPremiumPlan =
|
||||
plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE;
|
||||
|
||||
// Minimal query for free plans - only basic fields available on all plans
|
||||
// Note: audio_url requires Pro+, video_url requires Business+
|
||||
const freeQuery = `
|
||||
query GetTranscriptMinimal($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
transcript_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Standard query for pro plans - adds speakers, summary, sentences, and audio_url (Pro+)
|
||||
// Note: video_url requires Business+
|
||||
const proQuery = `
|
||||
query GetTranscriptBasic($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
speakers {
|
||||
name
|
||||
}
|
||||
sentences {
|
||||
index
|
||||
speaker_name
|
||||
text
|
||||
start_time
|
||||
end_time
|
||||
}
|
||||
summary {
|
||||
overview
|
||||
keywords
|
||||
action_items
|
||||
notes
|
||||
gist
|
||||
bullet_gist
|
||||
short_summary
|
||||
short_overview
|
||||
outline
|
||||
shorthand_bullet
|
||||
}
|
||||
meeting_info {
|
||||
summary_status
|
||||
}
|
||||
transcript_url
|
||||
audio_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Full query for business/enterprise - includes all fields
|
||||
const businessQuery = `
|
||||
query GetTranscriptFull($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
analytics {
|
||||
sentiments {
|
||||
positive_pct
|
||||
negative_pct
|
||||
neutral_pct
|
||||
}
|
||||
categories {
|
||||
questions
|
||||
tasks
|
||||
metrics
|
||||
date_times
|
||||
}
|
||||
speakers {
|
||||
speaker_id
|
||||
name
|
||||
duration
|
||||
word_count
|
||||
longest_monologue
|
||||
filler_words
|
||||
questions
|
||||
words_per_minute
|
||||
}
|
||||
}
|
||||
meeting_attendees {
|
||||
displayName
|
||||
email
|
||||
phoneNumber
|
||||
name
|
||||
location
|
||||
}
|
||||
meeting_attendance {
|
||||
name
|
||||
join_time
|
||||
leave_time
|
||||
}
|
||||
speakers {
|
||||
name
|
||||
}
|
||||
sentences {
|
||||
index
|
||||
speaker_name
|
||||
text
|
||||
start_time
|
||||
end_time
|
||||
ai_filters {
|
||||
task
|
||||
question
|
||||
sentiment
|
||||
}
|
||||
}
|
||||
summary {
|
||||
action_items
|
||||
overview
|
||||
keywords
|
||||
notes
|
||||
gist
|
||||
bullet_gist
|
||||
short_summary
|
||||
short_overview
|
||||
outline
|
||||
shorthand_bullet
|
||||
topics_discussed
|
||||
meeting_type
|
||||
transcript_chapters
|
||||
}
|
||||
meeting_info {
|
||||
summary_status
|
||||
}
|
||||
transcript_url
|
||||
audio_url
|
||||
video_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Select query based on plan
|
||||
const queryToUse = isPremiumPlan ? businessQuery :
|
||||
(plan === FIREFLIES_PLANS.PRO ? proQuery : freeQuery);
|
||||
|
||||
const planFeatures = {
|
||||
[FIREFLIES_PLANS.FREE]: 'basic fields only (no summary, no audio/video)',
|
||||
[FIREFLIES_PLANS.PRO]: 'summary, speakers, audio_url',
|
||||
[FIREFLIES_PLANS.BUSINESS]: 'full access including analytics, video_url',
|
||||
[FIREFLIES_PLANS.ENTERPRISE]: 'full access including analytics, video_url',
|
||||
};
|
||||
logger.debug(`using ${plan} plan query (${planFeatures[plan]})`);
|
||||
|
||||
try {
|
||||
return await this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: queryToUse,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Detect plan-specific errors
|
||||
const requiresBusiness = message.toLowerCase().includes('business or higher');
|
||||
const requiresPro = message.toLowerCase().includes('pro or higher');
|
||||
const planError = requiresBusiness || requiresPro ||
|
||||
message.toLowerCase().includes('higher plan') ||
|
||||
message.includes('Cannot query field');
|
||||
|
||||
// Fallback cascade: business -> pro -> free
|
||||
if (planError) {
|
||||
if (isPremiumPlan) {
|
||||
logger.warn(`Plan limitation detected (configured: ${plan}), falling back to pro query`);
|
||||
try {
|
||||
return await this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: proQuery,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
} catch (proError) {
|
||||
const proMessage = proError instanceof Error ? proError.message : String(proError);
|
||||
if (proMessage.toLowerCase().includes('plan') || proMessage.includes('Cannot query field')) {
|
||||
logger.warn('Pro query also failed, falling back to minimal free query');
|
||||
return this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: freeQuery,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
}
|
||||
throw proError;
|
||||
}
|
||||
} else if (plan === FIREFLIES_PLANS.PRO) {
|
||||
logger.warn(`Pro plan query failed (${requiresBusiness ? 'requires Business+' : 'unknown restriction'}), falling back to free query`);
|
||||
return this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: freeQuery,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
} else {
|
||||
// Already using free query - some field might still be restricted
|
||||
logger.error(
|
||||
'Fireflies API rejected the minimal free query. This may indicate: ' +
|
||||
'1) The transcript ID is invalid, or ' +
|
||||
'2) Your API key does not have access to this transcript, or ' +
|
||||
'3) An unexpected API restriction : open an issue'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMeetingDataWithRetry(
|
||||
meetingId: string,
|
||||
config: SummaryFetchConfig,
|
||||
plan: FirefliesPlan = FIREFLIES_PLANS.FREE
|
||||
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
|
||||
// immediate_only: single attempt, no retries
|
||||
if (config.strategy === 'immediate_only') {
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
logger.debug(`summary ready: ${ready}`);
|
||||
return { data: meetingData, summaryReady: ready };
|
||||
}
|
||||
|
||||
// immediate_with_retry: retry with linear backoff
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
|
||||
|
||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||
try {
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
|
||||
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
|
||||
|
||||
if (ready) {
|
||||
return { data: meetingData, summaryReady: true };
|
||||
}
|
||||
|
||||
if (attempt < config.retryAttempts) {
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
} else {
|
||||
logger.debug(`max retries reached, returning partial data`);
|
||||
return { data: meetingData, summaryReady: false };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
|
||||
|
||||
if (attempt === config.retryAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`retrying in ${delayMs}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch meeting data after retries');
|
||||
}
|
||||
|
||||
private async executeTranscriptQuery({
|
||||
meetingId,
|
||||
query,
|
||||
timeout,
|
||||
}: {
|
||||
meetingId: string;
|
||||
query: string;
|
||||
timeout?: number;
|
||||
}): Promise<FirefliesMeetingData> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.fireflies.ai/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { transcriptId: meetingId },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetails = `Fireflies API request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.text();
|
||||
if (errorBody) {
|
||||
errorDetails += `: ${errorBody}`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore if we can't read the response body
|
||||
}
|
||||
throw new Error(errorDetails);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
data?: { transcript?: Record<string, unknown> };
|
||||
errors?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
if (json.errors && json.errors.length > 0) {
|
||||
throw new Error(`Fireflies API error: ${json.errors[0]?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const transcript = json.data?.transcript;
|
||||
if (!transcript) {
|
||||
throw new Error('Invalid response from Fireflies API: missing transcript data');
|
||||
}
|
||||
|
||||
return this.transformMeetingData(transcript, meetingId);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private isSummaryReady(meetingData: FirefliesMeetingData): boolean {
|
||||
return (
|
||||
(meetingData.summary?.action_items?.length > 0) ||
|
||||
(meetingData.summary?.overview?.length > 0) ||
|
||||
meetingData.summary_status === 'completed'
|
||||
);
|
||||
}
|
||||
|
||||
private extractAllParticipants(transcript: Record<string, unknown>): FirefliesParticipant[] {
|
||||
const participantsWithEmails: FirefliesParticipant[] = [];
|
||||
const participantsNameOnly: FirefliesParticipant[] = [];
|
||||
|
||||
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
|
||||
logger.debug('participants field:', JSON.stringify(transcript.participants));
|
||||
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
|
||||
logger.debug('speakers field:', (transcript.speakers as Array<{ name: string }>)?.map((s) => s.name));
|
||||
logger.debug('meeting_attendance field:', (transcript.meeting_attendance as Array<{ name: string }>)?.map((a) => a.name));
|
||||
logger.debug('organizer_email:', transcript.organizer_email);
|
||||
|
||||
const isEmail = (str: string): boolean => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
|
||||
};
|
||||
|
||||
const isDuplicate = (name: string, email: string): boolean => {
|
||||
const nameLower = name.toLowerCase().trim();
|
||||
const emailLower = email.toLowerCase().trim();
|
||||
|
||||
return participantsWithEmails.some(p =>
|
||||
p.name.toLowerCase().trim() === nameLower ||
|
||||
(email && p.email.toLowerCase() === emailLower)
|
||||
) || participantsNameOnly.some(p =>
|
||||
p.name.toLowerCase().trim() === nameLower
|
||||
);
|
||||
};
|
||||
|
||||
// 1. Extract from legacy participants field (with emails)
|
||||
if (transcript.participants && Array.isArray(transcript.participants)) {
|
||||
transcript.participants.forEach((participant: string) => {
|
||||
const parts = participant.split(',').map(p => p.trim());
|
||||
|
||||
parts.forEach(part => {
|
||||
const emailMatch = part.match(/<([^>]+)>/);
|
||||
const email = emailMatch ? emailMatch[1] : '';
|
||||
const name = emailMatch
|
||||
? part.substring(0, part.indexOf('<')).trim()
|
||||
: part.trim();
|
||||
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping participant with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate(name, email)) {
|
||||
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else if (name) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extract from meeting_attendees field (structured)
|
||||
if (transcript.meeting_attendees && Array.isArray(transcript.meeting_attendees)) {
|
||||
transcript.meeting_attendees.forEach((attendee: Record<string, unknown>) => {
|
||||
const name = (attendee.displayName || attendee.name || '') as string;
|
||||
const email = (attendee.email || '') as string;
|
||||
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping attendee with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, email)) {
|
||||
if (email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Extract from speakers field (name only)
|
||||
if (transcript.speakers && Array.isArray(transcript.speakers)) {
|
||||
transcript.speakers.forEach((speaker: Record<string, unknown>) => {
|
||||
const name = (speaker.name || '') as string;
|
||||
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping speaker with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Extract from meeting_attendance field (name only)
|
||||
if (transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
|
||||
transcript.meeting_attendance.forEach((attendance: Record<string, unknown>) => {
|
||||
const name = (attendance.name || '') as string;
|
||||
|
||||
if (isEmail(name) || name.includes(',')) {
|
||||
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Add organizer email if available and not already included
|
||||
const organizerEmail = transcript.organizer_email as string | undefined;
|
||||
if (organizerEmail) {
|
||||
const existsWithEmail = participantsWithEmails.some(p =>
|
||||
p.email.toLowerCase() === organizerEmail.toLowerCase()
|
||||
);
|
||||
|
||||
if (!existsWithEmail) {
|
||||
let organizerName = '';
|
||||
|
||||
const emailUsername = organizerEmail.split('@')[0].toLowerCase();
|
||||
const emailNameVariations = [emailUsername];
|
||||
|
||||
if (transcript.speakers && Array.isArray(transcript.speakers)) {
|
||||
const potentialOrganizerSpeaker = transcript.speakers.find((speaker: Record<string, unknown>) => {
|
||||
const name = ((speaker.name || '') as string).toLowerCase();
|
||||
return emailNameVariations.some(variation =>
|
||||
name.includes(variation) || variation.includes(name)
|
||||
);
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (potentialOrganizerSpeaker) {
|
||||
organizerName = potentialOrganizerSpeaker.name as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (!organizerName && transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
|
||||
const potentialOrganizerAttendance = transcript.meeting_attendance.find((attendance: Record<string, unknown>) => {
|
||||
const name = ((attendance.name || '') as string).toLowerCase();
|
||||
return emailNameVariations.some(variation =>
|
||||
name.includes(variation) || variation.includes(name)
|
||||
);
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (potentialOrganizerAttendance) {
|
||||
organizerName = potentialOrganizerAttendance.name as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (organizerName) {
|
||||
participantsWithEmails.push({ name: organizerName, email: organizerEmail });
|
||||
|
||||
const nameIndex = participantsNameOnly.findIndex(p =>
|
||||
p.name.toLowerCase().includes(organizerName.toLowerCase()) ||
|
||||
organizerName.toLowerCase().includes(p.name.toLowerCase())
|
||||
);
|
||||
if (nameIndex !== -1) {
|
||||
participantsNameOnly.splice(nameIndex, 1);
|
||||
}
|
||||
} else {
|
||||
participantsWithEmails.push({ name: 'Meeting Organizer', email: organizerEmail });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
|
||||
|
||||
logger.debug('=== EXTRACTED PARTICIPANTS ===');
|
||||
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
|
||||
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
|
||||
logger.debug('Total:', allParticipants.length);
|
||||
|
||||
return allParticipants;
|
||||
}
|
||||
|
||||
private transformMeetingData(transcript: Record<string, unknown>, meetingId: string): FirefliesMeetingData {
|
||||
let dateString: string;
|
||||
if (transcript.date) {
|
||||
if (typeof transcript.date === 'number') {
|
||||
dateString = new Date(transcript.date).toISOString();
|
||||
} else if (typeof transcript.date === 'string') {
|
||||
const parsed = Number(transcript.date);
|
||||
if (!isNaN(parsed)) {
|
||||
dateString = new Date(parsed).toISOString();
|
||||
} else {
|
||||
dateString = transcript.date;
|
||||
}
|
||||
} else {
|
||||
dateString = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
dateString = new Date().toISOString();
|
||||
}
|
||||
|
||||
const summary = transcript.summary as Record<string, unknown> | undefined;
|
||||
const analytics = transcript.analytics as Record<string, unknown> | undefined;
|
||||
const sentiments = analytics?.sentiments as Record<string, number> | undefined;
|
||||
const categories = analytics?.categories as Record<string, number> | undefined;
|
||||
const speakersAnalytics = analytics?.speakers as Array<Record<string, unknown>> | undefined;
|
||||
const meetingInfo = transcript.meeting_info as Record<string, unknown> | undefined;
|
||||
|
||||
// Transform sentences array
|
||||
const rawSentences = transcript.sentences as Array<Record<string, unknown>> | undefined;
|
||||
const sentences = rawSentences?.map(s => ({
|
||||
index: (s.index as number) || 0,
|
||||
speaker_name: (s.speaker_name as string) || 'Unknown',
|
||||
text: (s.text as string) || '',
|
||||
start_time: (s.start_time as string) || '0',
|
||||
end_time: (s.end_time as string) || '0',
|
||||
ai_filters: s.ai_filters as { task?: boolean; question?: boolean; sentiment?: string } | undefined,
|
||||
}));
|
||||
|
||||
// Transform speaker analytics
|
||||
const speakers = speakersAnalytics?.map(sp => ({
|
||||
speaker_id: (sp.speaker_id as string) || '',
|
||||
name: (sp.name as string) || 'Unknown',
|
||||
duration: (sp.duration as number) || 0,
|
||||
word_count: (sp.word_count as number) || 0,
|
||||
longest_monologue: (sp.longest_monologue as number) || 0,
|
||||
filler_words: (sp.filler_words as number) || 0,
|
||||
questions: (sp.questions as number) || 0,
|
||||
words_per_minute: (sp.words_per_minute as number) || 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: (transcript.id as string) || meetingId,
|
||||
title: (transcript.title as string) || 'Untitled Meeting',
|
||||
date: dateString,
|
||||
duration: (transcript.duration as number) || 0,
|
||||
participants: this.extractAllParticipants(transcript),
|
||||
organizer_email: transcript.organizer_email as string | undefined,
|
||||
sentences,
|
||||
summary: {
|
||||
// action_items can be string or array - normalize to array
|
||||
action_items: Array.isArray(summary?.action_items)
|
||||
? summary.action_items as string[]
|
||||
: (typeof summary?.action_items === 'string' && summary.action_items.trim()
|
||||
? summary.action_items.split('\n').filter((item: string) => item.trim())
|
||||
: []),
|
||||
overview: (summary?.overview as string) || '',
|
||||
notes: summary?.notes as string | undefined,
|
||||
gist: summary?.gist as string | undefined,
|
||||
bullet_gist: summary?.bullet_gist as string | undefined,
|
||||
short_summary: summary?.short_summary as string | undefined,
|
||||
short_overview: summary?.short_overview as string | undefined,
|
||||
outline: summary?.outline as string | undefined,
|
||||
shorthand_bullet: summary?.shorthand_bullet as string | undefined,
|
||||
keywords: summary?.keywords as string[] | undefined,
|
||||
topics_discussed: summary?.topics_discussed as string[] | undefined,
|
||||
meeting_type: summary?.meeting_type as string | undefined,
|
||||
transcript_chapters: summary?.transcript_chapters as string[] | undefined,
|
||||
},
|
||||
analytics: (sentiments || categories || speakers) ? {
|
||||
sentiments: sentiments ? {
|
||||
positive_pct: sentiments.positive_pct || 0,
|
||||
negative_pct: sentiments.negative_pct || 0,
|
||||
neutral_pct: sentiments.neutral_pct || 0,
|
||||
} : undefined,
|
||||
categories: categories ? {
|
||||
questions: categories.questions || 0,
|
||||
tasks: categories.tasks || 0,
|
||||
metrics: categories.metrics || 0,
|
||||
date_times: categories.date_times || 0,
|
||||
} : undefined,
|
||||
speakers,
|
||||
} : undefined,
|
||||
meeting_info: meetingInfo ? {
|
||||
summary_status: meetingInfo.summary_status as string | undefined,
|
||||
} : undefined,
|
||||
// URLs by plan availability:
|
||||
transcript_url: (transcript.transcript_url as string) || `https://app.fireflies.ai/view/${meetingId}`,
|
||||
audio_url: transcript.audio_url as string | undefined, // Pro+
|
||||
video_url: transcript.video_url as string | undefined, // Business+
|
||||
meeting_link: transcript.meeting_link as string | undefined, // All plans
|
||||
summary_status: (meetingInfo?.summary_status as string) || (transcript.summary_status as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeTranscriptListQuery(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const response = await fetch('https://api.fireflies.ai/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`Fireflies transcripts request failed: ${response.status} ${errorBody}`);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
data?: { transcripts?: Array<Record<string, unknown>> };
|
||||
errors?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
if (json.errors && json.errors.length > 0) {
|
||||
const message = json.errors[0]?.message || 'Unknown error';
|
||||
throw new Error(`Fireflies API error: ${message}`);
|
||||
}
|
||||
|
||||
return json.data?.transcripts ?? [];
|
||||
}
|
||||
|
||||
private normalizeDate(dateValue: unknown): string | undefined {
|
||||
if (!dateValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof dateValue === 'number') {
|
||||
return new Date(dateValue).toISOString();
|
||||
}
|
||||
|
||||
if (typeof dateValue === 'string') {
|
||||
const parsed = Number(dateValue);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toISOString();
|
||||
}
|
||||
return dateValue;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import type { FirefliesMeetingData, FirefliesSentence, MeetingCreateInput } from './types';
|
||||
|
||||
export class MeetingFormatter {
|
||||
// Format timestamp from seconds to MM:SS
|
||||
private static formatTimestamp(timeStr: string): string {
|
||||
const seconds = parseFloat(timeStr);
|
||||
if (isNaN(seconds)) return '00:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format full transcript from sentences
|
||||
private static formatTranscript(sentences: FirefliesSentence[]): string {
|
||||
if (!sentences || sentences.length === 0) return '';
|
||||
|
||||
let transcript = '';
|
||||
let currentSpeaker = '';
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const timestamp = this.formatTimestamp(sentence.start_time);
|
||||
const speaker = sentence.speaker_name || 'Unknown';
|
||||
|
||||
// Add speaker header when speaker changes
|
||||
if (speaker !== currentSpeaker) {
|
||||
currentSpeaker = speaker;
|
||||
transcript += `\n**${speaker}** [${timestamp}]\n`;
|
||||
}
|
||||
|
||||
transcript += `${sentence.text} `;
|
||||
}
|
||||
|
||||
return transcript.trim();
|
||||
}
|
||||
|
||||
static formatNoteBody(meetingData: FirefliesMeetingData): string {
|
||||
const meetingDate = meetingData.date ? new Date(meetingData.date) : null;
|
||||
const hasValidDate = meetingDate instanceof Date && !Number.isNaN(meetingDate.getTime());
|
||||
const formattedDate = hasValidDate
|
||||
? meetingDate.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: 'Unknown date';
|
||||
const durationMinutes = Math.round(meetingData.duration);
|
||||
|
||||
let noteBody = `**Date:** ${formattedDate}\n`;
|
||||
noteBody += `**Duration:** ${durationMinutes} minutes\n`;
|
||||
|
||||
if (meetingData.participants.length > 0) {
|
||||
const participantNames = meetingData.participants.map(p => p.name).join(', ');
|
||||
noteBody += `**Participants:** ${participantNames}\n`;
|
||||
}
|
||||
|
||||
// Overview section
|
||||
if (meetingData.summary?.overview) {
|
||||
noteBody += `\n## Overview\n${meetingData.summary.overview}\n`;
|
||||
}
|
||||
|
||||
// Detailed AI Notes (the rich content from Fireflies)
|
||||
if (meetingData.summary?.notes) {
|
||||
noteBody += `\n## Meeting Notes\n${meetingData.summary.notes}\n`;
|
||||
}
|
||||
|
||||
// Bullet gist with emojis (if available and different from notes)
|
||||
if (meetingData.summary?.bullet_gist && !meetingData.summary?.notes) {
|
||||
noteBody += `\n## Key Points\n${meetingData.summary.bullet_gist}\n`;
|
||||
}
|
||||
|
||||
// Meeting outline with timestamps (shorthand_bullet contains the timestamped outline)
|
||||
const outline = meetingData.summary?.outline || meetingData.summary?.shorthand_bullet;
|
||||
if (outline) {
|
||||
noteBody += `\n## Outline\n${outline}\n`;
|
||||
}
|
||||
|
||||
// Key topics
|
||||
if (meetingData.summary?.topics_discussed?.length) {
|
||||
noteBody += `\n## Key Topics\n`;
|
||||
meetingData.summary.topics_discussed.forEach(topic => {
|
||||
noteBody += `- ${topic}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Action items
|
||||
if (meetingData.summary?.action_items?.length) {
|
||||
noteBody += `\n## Action Items\n`;
|
||||
meetingData.summary.action_items.forEach(item => {
|
||||
noteBody += `- [ ] ${item}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Insights section
|
||||
noteBody += `\n## Insights\n`;
|
||||
|
||||
if (meetingData.summary?.keywords?.length) {
|
||||
noteBody += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
|
||||
}
|
||||
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
noteBody += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
|
||||
}
|
||||
|
||||
if (meetingData.summary?.meeting_type) {
|
||||
noteBody += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
|
||||
}
|
||||
|
||||
// Speaker analytics (Business+)
|
||||
if (meetingData.analytics?.speakers?.length) {
|
||||
noteBody += `\n### Speaker Stats\n`;
|
||||
for (const speaker of meetingData.analytics.speakers) {
|
||||
const talkTime = Math.round(speaker.duration / 60);
|
||||
noteBody += `- **${speaker.name}**: ${talkTime} min talk time, ${speaker.word_count} words, ${speaker.questions} questions\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Meeting metrics (Business+)
|
||||
if (meetingData.analytics?.categories) {
|
||||
const cats = meetingData.analytics.categories;
|
||||
noteBody += `\n### Meeting Metrics\n`;
|
||||
noteBody += `- Questions asked: ${cats.questions}\n`;
|
||||
noteBody += `- Tasks identified: ${cats.tasks}\n`;
|
||||
if (cats.metrics > 0) noteBody += `- Metrics mentioned: ${cats.metrics}\n`;
|
||||
if (cats.date_times > 0) noteBody += `- Dates/times discussed: ${cats.date_times}\n`;
|
||||
}
|
||||
|
||||
// Resources section
|
||||
noteBody += `\n## Resources\n`;
|
||||
noteBody += `[View Full Transcript on Fireflies](${meetingData.transcript_url})\n`;
|
||||
|
||||
if (meetingData.video_url) {
|
||||
noteBody += `[Watch Video Recording](${meetingData.video_url})\n`;
|
||||
}
|
||||
|
||||
if (meetingData.audio_url) {
|
||||
noteBody += `[Listen to Audio](${meetingData.audio_url})\n`;
|
||||
}
|
||||
|
||||
if (meetingData.meeting_link) {
|
||||
noteBody += `[Original Meeting Link](${meetingData.meeting_link})\n`;
|
||||
}
|
||||
|
||||
return noteBody;
|
||||
}
|
||||
|
||||
static toMeetingCreateInput(
|
||||
meetingData: FirefliesMeetingData,
|
||||
noteId?: string
|
||||
): MeetingCreateInput {
|
||||
const durationMinutes = Math.round(meetingData.duration);
|
||||
const hasSummary = Boolean(meetingData.summary?.overview || meetingData.summary?.action_items?.length);
|
||||
const hasAnalytics = Boolean(meetingData.analytics?.sentiments);
|
||||
|
||||
// Build input object with only defined values (omit null fields)
|
||||
const input: MeetingCreateInput = {
|
||||
name: meetingData.title,
|
||||
meetingDate: meetingData.date,
|
||||
duration: durationMinutes,
|
||||
actionItemsCount: meetingData.summary?.action_items?.length || 0,
|
||||
firefliesMeetingId: meetingData.id,
|
||||
};
|
||||
|
||||
// Add direct relationship to note if noteId is provided
|
||||
if (noteId) {
|
||||
input.noteId = noteId;
|
||||
}
|
||||
|
||||
// Basic fields (All plans)
|
||||
if (meetingData.organizer_email) {
|
||||
input.organizerEmail = meetingData.organizer_email;
|
||||
}
|
||||
if (meetingData.transcript_url?.trim()) {
|
||||
input.transcriptUrl = {
|
||||
primaryLinkUrl: meetingData.transcript_url,
|
||||
primaryLinkLabel: 'View Transcript'
|
||||
};
|
||||
}
|
||||
if (meetingData.meeting_link?.trim()) {
|
||||
input.meetingLink = {
|
||||
primaryLinkUrl: meetingData.meeting_link,
|
||||
primaryLinkLabel: 'Join Meeting'
|
||||
};
|
||||
}
|
||||
|
||||
// Pro+ fields (transcript, summary, notes, keywords, audio)
|
||||
if (meetingData.sentences?.length) {
|
||||
input.transcript = this.formatTranscript(meetingData.sentences);
|
||||
}
|
||||
if (meetingData.summary?.overview) {
|
||||
input.overview = meetingData.summary.overview;
|
||||
}
|
||||
if (meetingData.summary?.notes) {
|
||||
input.notes = meetingData.summary.notes;
|
||||
}
|
||||
if (meetingData.summary?.keywords?.length) {
|
||||
input.keywords = meetingData.summary.keywords.join(', ');
|
||||
}
|
||||
if (meetingData.audio_url?.trim()) {
|
||||
input.audioUrl = {
|
||||
primaryLinkUrl: meetingData.audio_url,
|
||||
primaryLinkLabel: 'Listen to Audio'
|
||||
};
|
||||
}
|
||||
|
||||
// Business+ fields (analytics, video, detailed summary)
|
||||
if (meetingData.summary?.meeting_type) {
|
||||
input.meetingType = meetingData.summary.meeting_type;
|
||||
}
|
||||
if (meetingData.summary?.topics_discussed?.length) {
|
||||
input.topics = meetingData.summary.topics_discussed.join(', ');
|
||||
}
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
input.positivePercent = sentiments.positive_pct;
|
||||
input.negativePercent = sentiments.negative_pct;
|
||||
input.neutralPercent = sentiments.neutral_pct;
|
||||
}
|
||||
if (meetingData.video_url?.trim()) {
|
||||
input.videoUrl = {
|
||||
primaryLinkUrl: meetingData.video_url,
|
||||
primaryLinkLabel: 'Watch Video'
|
||||
};
|
||||
}
|
||||
|
||||
// Import status based on data completeness
|
||||
const isPartial = !hasSummary && !hasAnalytics;
|
||||
input.importStatus = isPartial ? 'PARTIAL' : 'SUCCESS';
|
||||
input.lastImportAttempt = new Date().toISOString();
|
||||
input.importAttempts = 1;
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
static toFailedMeetingCreateInput(
|
||||
meetingId: string,
|
||||
title: string,
|
||||
error: string,
|
||||
attempts: number = 1
|
||||
): MeetingCreateInput {
|
||||
const currentDate = new Date().toISOString();
|
||||
|
||||
return {
|
||||
name: title || `Failed Meeting Import - ${meetingId}`,
|
||||
meetingDate: currentDate,
|
||||
duration: 0,
|
||||
actionItemsCount: 0,
|
||||
firefliesMeetingId: meetingId,
|
||||
importStatus: 'FAILED',
|
||||
importError: error,
|
||||
lastImportAttempt: currentDate,
|
||||
importAttempts: attempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { type FirefliesApiClient } from './fireflies-api-client';
|
||||
import { MeetingFormatter } from './formatters';
|
||||
import { createLogger } from './logger';
|
||||
import { type TwentyCrmService } from './twenty-crm-service';
|
||||
import type {
|
||||
FirefliesPlan,
|
||||
FirefliesTranscriptListOptions,
|
||||
SummaryFetchConfig,
|
||||
} from './types';
|
||||
|
||||
const logger = createLogger('historical-importer');
|
||||
|
||||
export type HistoricalImportFilters = FirefliesTranscriptListOptions;
|
||||
|
||||
export type HistoricalImportOptions = {
|
||||
dryRun?: boolean;
|
||||
autoCreateContacts: boolean;
|
||||
summaryConfig: SummaryFetchConfig;
|
||||
plan: FirefliesPlan;
|
||||
};
|
||||
|
||||
export type HistoricalImportResult = {
|
||||
dryRun: boolean;
|
||||
totalListed: number;
|
||||
imported: number;
|
||||
skippedExisting: number;
|
||||
failed: Array<{ meetingId: string; reason: string }>;
|
||||
summaryPending: number;
|
||||
statuses: Array<{
|
||||
meetingId: string;
|
||||
title?: string;
|
||||
status: 'imported' | 'skipped_existing' | 'failed' | 'dry_run' | 'pending_summary';
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export class HistoricalImporter {
|
||||
private firefliesClient: FirefliesApiClient;
|
||||
private twentyService: TwentyCrmService;
|
||||
|
||||
constructor(firefliesClient: FirefliesApiClient, twentyService: TwentyCrmService) {
|
||||
this.firefliesClient = firefliesClient;
|
||||
this.twentyService = twentyService;
|
||||
}
|
||||
|
||||
async run(
|
||||
filters: HistoricalImportFilters,
|
||||
options: HistoricalImportOptions,
|
||||
): Promise<HistoricalImportResult> {
|
||||
const { dryRun = false, autoCreateContacts, summaryConfig, plan } = options;
|
||||
|
||||
logger.info('Listing Fireflies transcripts for historical import...');
|
||||
const transcripts = await this.firefliesClient.listTranscripts(filters);
|
||||
logger.info(`Found ${transcripts.length} transcript(s) to process`);
|
||||
|
||||
let imported = 0;
|
||||
let skippedExisting = 0;
|
||||
let summaryPending = 0;
|
||||
const failed: Array<{ meetingId: string; reason: string }> = [];
|
||||
const statuses: HistoricalImportResult['statuses'] = [];
|
||||
|
||||
for (const transcript of transcripts) {
|
||||
const meetingId = transcript.id;
|
||||
|
||||
try {
|
||||
const existing = await this.twentyService.findMeetingByFirefliesId(meetingId);
|
||||
if (existing) {
|
||||
logger.debug(`Skipping ${meetingId}: already exists in Twenty (${existing.id})`);
|
||||
skippedExisting += 1;
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: transcript.title,
|
||||
status: 'skipped_existing',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Fetching meeting ${meetingId} details`);
|
||||
const { data: meetingData, summaryReady } =
|
||||
await this.firefliesClient.fetchMeetingDataWithRetry(
|
||||
meetingId,
|
||||
summaryConfig,
|
||||
plan,
|
||||
);
|
||||
|
||||
const isPendingSummary = summaryReady === false;
|
||||
if (isPendingSummary) {
|
||||
summaryPending += 1;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
logger.info(`[dry-run] Would import meeting "${meetingData.title}" (${meetingId})`);
|
||||
imported += 1;
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: meetingData.title,
|
||||
status: isPendingSummary ? 'pending_summary' : 'dry_run',
|
||||
reason: isPendingSummary ? 'summary not ready' : undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { matchedContacts, unmatchedParticipants } =
|
||||
await this.twentyService.matchParticipantsToContacts(
|
||||
meetingData.participants,
|
||||
);
|
||||
|
||||
const newContactIds = autoCreateContacts
|
||||
? await this.twentyService.createContactsForUnmatched(unmatchedParticipants)
|
||||
: [];
|
||||
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
|
||||
|
||||
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
|
||||
const noteId = await this.twentyService.createNoteOnly(
|
||||
`Meeting: ${meetingData.title}`,
|
||||
noteBody,
|
||||
);
|
||||
|
||||
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
|
||||
const createdMeetingId = await this.twentyService.createMeeting(meetingInput);
|
||||
|
||||
for (const contactId of allContactIds) {
|
||||
await this.twentyService.createNoteTarget(noteId, contactId);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Imported meeting "${meetingData.title}" (${meetingId}) as ${createdMeetingId}`,
|
||||
);
|
||||
imported += 1;
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: meetingData.title,
|
||||
status: isPendingSummary ? 'pending_summary' : 'imported',
|
||||
reason: isPendingSummary ? 'summary not ready' : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to import meeting ${meetingId}: ${reason}`);
|
||||
failed.push({ meetingId, reason });
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: transcript.title,
|
||||
status: 'failed',
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
totalListed: transcripts.length,
|
||||
imported,
|
||||
skippedExisting,
|
||||
failed,
|
||||
summaryPending,
|
||||
statuses,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
// Main exports for the Fireflies integration
|
||||
export { config, main } from './receive-fireflies-notes';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
FirefliesMeetingData,
|
||||
FirefliesParticipant,
|
||||
FirefliesWebhookPayload,
|
||||
ProcessResult,
|
||||
SummaryFetchConfig,
|
||||
SummaryStrategy
|
||||
} from './types';
|
||||
|
||||
// Services (for advanced usage)
|
||||
export { FirefliesApiClient } from './fireflies-api-client';
|
||||
export { MeetingFormatter } from './formatters';
|
||||
export { TwentyCrmService } from './twenty-crm-service';
|
||||
export { WebhookHandler } from './webhook-handler';
|
||||
|
||||
// Objects
|
||||
export { Meeting } from './objects';
|
||||
|
||||
// Utilities
|
||||
export { createLogger } from './logger';
|
||||
export { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts, toBoolean } from './utils';
|
||||
export {
|
||||
getWebhookSecretFingerprint,
|
||||
isValidFirefliesPayload,
|
||||
verifyWebhookSignature
|
||||
} from './webhook-validator';
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
||||
|
||||
export type LoggerConfig = {
|
||||
logLevel: LogLevel;
|
||||
isTestEnvironment: boolean;
|
||||
captureForResponse: boolean;
|
||||
};
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
silent: 4,
|
||||
};
|
||||
|
||||
const loggerRegistry = new Set<AppLogger>();
|
||||
|
||||
export class AppLogger {
|
||||
private config: LoggerConfig;
|
||||
private context: string;
|
||||
private capturedLogs: string[] = [];
|
||||
|
||||
constructor(context: string) {
|
||||
this.context = context;
|
||||
this.config = {
|
||||
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
|
||||
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
|
||||
captureForResponse: process.env.CAPTURE_LOGS === 'true',
|
||||
};
|
||||
|
||||
// Silence logs in test environment unless explicitly overridden
|
||||
if (this.config.isTestEnvironment && process.env.LOG_LEVEL === undefined) {
|
||||
this.config.logLevel = 'silent';
|
||||
}
|
||||
}
|
||||
|
||||
private parseLogLevel(level: string): LogLevel {
|
||||
const normalizedLevel = level.toLowerCase() as LogLevel;
|
||||
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
|
||||
}
|
||||
|
||||
private safeStringify(value: unknown): string {
|
||||
try {
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '[unserializable]';
|
||||
}
|
||||
}
|
||||
|
||||
private captureLog(level: LogLevel, message: string, ...args: unknown[]): void {
|
||||
if (!this.config.captureForResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedMessage =
|
||||
args.length > 0
|
||||
? `${message} ${args.map((arg) => this.safeStringify(arg)).join(' ')}`
|
||||
: message;
|
||||
|
||||
this.capturedLogs.push(
|
||||
`[${timestamp}] [${level.toUpperCase()}] [${this.context}] ${formattedMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('debug', message, ...args);
|
||||
if (this.shouldLog('debug')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('info', message, ...args);
|
||||
if (this.shouldLog('info')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('warn', message, ...args);
|
||||
if (this.shouldLog('warn')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('error', message, ...args);
|
||||
if (this.shouldLog('error')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// For fatal errors, security issues, or data corruption - always visible
|
||||
critical(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('error', `CRITICAL: ${message}`, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
|
||||
}
|
||||
|
||||
getCapturedLogs(): string[] {
|
||||
return [...this.capturedLogs];
|
||||
}
|
||||
|
||||
clearCapturedLogs(): void {
|
||||
this.capturedLogs = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const createLogger = (context: string): AppLogger => {
|
||||
const logger = new AppLogger(context);
|
||||
loggerRegistry.add(logger);
|
||||
return logger;
|
||||
};
|
||||
|
||||
export const removeLogger = (logger: AppLogger): void => {
|
||||
loggerRegistry.delete(logger);
|
||||
};
|
||||
|
||||
export const getAllCapturedLogs = (): string[] => {
|
||||
const allLogs: string[] = [];
|
||||
for (const logger of loggerRegistry) {
|
||||
allLogs.push(...logger.getCapturedLogs());
|
||||
}
|
||||
return allLogs.sort();
|
||||
};
|
||||
|
||||
export const clearAllCapturedLogs = (): void => {
|
||||
for (const logger of loggerRegistry) {
|
||||
logger.clearCapturedLogs();
|
||||
}
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export { Meeting } from './meeting';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Object } from 'twenty-sdk';
|
||||
import { ObjectMetadata } from 'twenty-sdk/application';
|
||||
|
||||
@Object({
|
||||
@ObjectMetadata({
|
||||
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
|
||||
nameSingular: 'meeting',
|
||||
namePlural: 'meetings',
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { type FunctionConfig } from 'twenty-sdk';
|
||||
import type { ProcessResult } from './types';
|
||||
import { WebhookHandler } from './webhook-handler';
|
||||
|
||||
export const main = async (
|
||||
params: unknown,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ProcessResult> => {
|
||||
const handler = new WebhookHandler();
|
||||
return handler.handle(params, headers);
|
||||
};
|
||||
|
||||
export const config: FunctionConfig = {
|
||||
universalIdentifier: '2d3ea303-667c-4bbe-9e3d-db6ffb9d6c74',
|
||||
name: 'receive-fireflies-notes',
|
||||
description:
|
||||
'Receives Fireflies webhooks, fetches meeting summaries, and stores them in Twenty.',
|
||||
timeoutSeconds: 30,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'a2117dc1-7674-4c7e-9d70-9feb9820e9e8',
|
||||
type: 'route',
|
||||
path: '/webhook/fireflies',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
// Fireflies API Types
|
||||
export type FirefliesParticipant = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FirefliesWebhookPayload = {
|
||||
meetingId: string;
|
||||
eventType: string;
|
||||
clientReferenceId?: string;
|
||||
};
|
||||
|
||||
// Transcript sentence from Fireflies API
|
||||
export type FirefliesSentence = {
|
||||
index: number;
|
||||
speaker_name: string;
|
||||
text: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
ai_filters?: {
|
||||
task?: boolean;
|
||||
question?: boolean;
|
||||
sentiment?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Speaker analytics from Fireflies API (Business+)
|
||||
export type FirefliesSpeakerAnalytics = {
|
||||
speaker_id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
word_count: number;
|
||||
longest_monologue: number;
|
||||
filler_words: number;
|
||||
questions: number;
|
||||
words_per_minute: number;
|
||||
};
|
||||
|
||||
// Based on Fireflies GraphQL API transcript schema
|
||||
// See: https://docs.fireflies.ai/graphql-api/query/transcript
|
||||
export type FirefliesMeetingData = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
duration: number;
|
||||
participants: FirefliesParticipant[];
|
||||
organizer_email?: string;
|
||||
// Full transcript (Pro+)
|
||||
sentences?: FirefliesSentence[];
|
||||
summary: {
|
||||
// Pro+ fields
|
||||
action_items: string[];
|
||||
keywords?: string[];
|
||||
overview: string;
|
||||
notes?: string; // Detailed AI-generated meeting notes
|
||||
gist?: string; // 1-sentence summary
|
||||
bullet_gist?: string; // Bullet point summary with emojis
|
||||
short_summary?: string; // Single paragraph summary
|
||||
short_overview?: string; // Brief overview
|
||||
outline?: string; // Meeting outline with timestamps
|
||||
shorthand_bullet?: string;
|
||||
// Business+ fields
|
||||
topics_discussed?: string[];
|
||||
meeting_type?: string;
|
||||
transcript_chapters?: string[];
|
||||
};
|
||||
// Business+ analytics
|
||||
analytics?: {
|
||||
sentiments?: {
|
||||
positive_pct: number;
|
||||
negative_pct: number;
|
||||
neutral_pct: number;
|
||||
};
|
||||
categories?: {
|
||||
questions: number;
|
||||
tasks: number;
|
||||
metrics: number;
|
||||
date_times: number;
|
||||
};
|
||||
speakers?: FirefliesSpeakerAnalytics[];
|
||||
};
|
||||
meeting_info?: {
|
||||
summary_status?: string;
|
||||
};
|
||||
// URLs
|
||||
transcript_url: string;
|
||||
audio_url?: string; // Pro+
|
||||
video_url?: string; // Business+
|
||||
meeting_link?: string; // All plans
|
||||
summary_status?: string;
|
||||
};
|
||||
|
||||
export type FirefliesTranscriptListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
duration?: number;
|
||||
organizer_email?: string;
|
||||
participants?: string[];
|
||||
transcript_url?: string;
|
||||
meeting_link?: string;
|
||||
summary_status?: string;
|
||||
};
|
||||
|
||||
export type FirefliesTranscriptListOptions = {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
organizers?: string[];
|
||||
participants?: string[];
|
||||
hostEmail?: string;
|
||||
participantEmail?: string;
|
||||
userId?: string;
|
||||
channelId?: string;
|
||||
mine?: boolean;
|
||||
fromDate?: number;
|
||||
toDate?: number;
|
||||
pageSize?: number;
|
||||
maxRecords?: number;
|
||||
};
|
||||
|
||||
// Configuration Types
|
||||
export type SummaryStrategy = 'immediate_only' | 'immediate_with_retry' | 'delayed_polling' | 'basic_only';
|
||||
|
||||
export type SummaryFetchConfig = {
|
||||
strategy: SummaryStrategy;
|
||||
retryAttempts: number;
|
||||
retryDelay: number;
|
||||
pollInterval: number;
|
||||
maxPolls: number;
|
||||
};
|
||||
|
||||
export const FIREFLIES_PLANS = {
|
||||
FREE: 'free',
|
||||
PRO: 'pro',
|
||||
BUSINESS: 'business',
|
||||
ENTERPRISE: 'enterprise',
|
||||
} as const;
|
||||
|
||||
export type FirefliesPlan = typeof FIREFLIES_PLANS[keyof typeof FIREFLIES_PLANS];
|
||||
|
||||
export type WebhookConfig = {
|
||||
secret: string;
|
||||
apiUrl: string;
|
||||
};
|
||||
|
||||
// Processing Result Types
|
||||
export type ProcessResult = {
|
||||
success: boolean;
|
||||
meetingId?: string;
|
||||
noteIds?: string[];
|
||||
newContacts?: string[];
|
||||
errors?: string[];
|
||||
debug?: string[];
|
||||
summaryReady?: boolean;
|
||||
summaryPending?: boolean;
|
||||
enhancementScheduled?: boolean;
|
||||
actionItemsCount?: number;
|
||||
sentimentAnalysis?: {
|
||||
positive_pct: number;
|
||||
negative_pct: number;
|
||||
neutral_pct: number;
|
||||
};
|
||||
meetingType?: string;
|
||||
keyTopics?: string[];
|
||||
};
|
||||
|
||||
// Twenty CRM Types
|
||||
export type GraphQLResponse<T> = {
|
||||
data: T;
|
||||
errors?: Array<{
|
||||
message?: string;
|
||||
extensions?: { code?: string }
|
||||
}>;
|
||||
};
|
||||
|
||||
export type IdNode = { id: string };
|
||||
|
||||
export type FindMeetingResponse = {
|
||||
meetings: { edges: Array<{ node: IdNode }> };
|
||||
};
|
||||
|
||||
export type FindPeopleResponse = {
|
||||
people: { edges: Array<{ node: { id: string; emails: { primaryEmail: string } } }> };
|
||||
};
|
||||
|
||||
export type CreatePersonResponse = {
|
||||
createPerson: { id: string }
|
||||
};
|
||||
|
||||
export type CreateNoteResponse = {
|
||||
createNote: { id: string }
|
||||
};
|
||||
|
||||
export type CreateMeetingResponse = {
|
||||
createMeeting: { id: string }
|
||||
};
|
||||
|
||||
export type Contact = {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Twenty CRM Meeting custom field input
|
||||
// Maps to fields defined in add-meeting-fields.ts
|
||||
export type MeetingCreateInput = {
|
||||
name: string;
|
||||
noteId?: string | null;
|
||||
// Basic fields (All plans)
|
||||
meetingDate: string;
|
||||
duration: number;
|
||||
firefliesMeetingId: string;
|
||||
organizerEmail?: string | null;
|
||||
transcriptUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
meetingLink?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
// Pro+ fields
|
||||
transcript?: string | null;
|
||||
overview?: string | null;
|
||||
notes?: string | null;
|
||||
keywords?: string | null;
|
||||
audioUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
// Business+ fields
|
||||
meetingType?: string | null;
|
||||
topics?: string | null;
|
||||
actionItemsCount: number;
|
||||
positivePercent?: number | null;
|
||||
negativePercent?: number | null;
|
||||
neutralPercent?: number | null;
|
||||
videoUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
// Import tracking
|
||||
importStatus?: 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'PENDING' | 'RETRYING' | null;
|
||||
importError?: string | null;
|
||||
lastImportAttempt?: string | null;
|
||||
importAttempts?: number | null;
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { FIREFLIES_PLANS, type FirefliesPlan, type SummaryFetchConfig, type SummaryStrategy } from './types';
|
||||
|
||||
export const toBoolean = (value: string | undefined, defaultValue: boolean): boolean => {
|
||||
if (value === undefined) return defaultValue;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes';
|
||||
};
|
||||
|
||||
export const getApiUrl = (): string => {
|
||||
return process.env.SERVER_URL || 'http://localhost:3000';
|
||||
};
|
||||
|
||||
export const getSummaryFetchConfig = (): SummaryFetchConfig => {
|
||||
const strategy = (process.env.FIREFLIES_SUMMARY_STRATEGY as SummaryStrategy) || 'immediate_with_retry';
|
||||
|
||||
// Ultra-conservative defaults to respect 50 requests/day API limit
|
||||
// With 3 attempts at 15-minute intervals, max 3 API calls per webhook (45 minutes total)
|
||||
return {
|
||||
strategy,
|
||||
retryAttempts: parseInt(process.env.FIREFLIES_RETRY_ATTEMPTS || '3', 10),
|
||||
retryDelay: parseInt(process.env.FIREFLIES_RETRY_DELAY || '120000', 10), // 2 minutes
|
||||
pollInterval: parseInt(process.env.FIREFLIES_POLL_INTERVAL || '120000', 10), // 2 minutes
|
||||
maxPolls: parseInt(process.env.FIREFLIES_MAX_POLLS || '3', 10),
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldAutoCreateContacts = (): boolean => {
|
||||
return toBoolean(process.env.AUTO_CREATE_CONTACTS, true);
|
||||
};
|
||||
|
||||
export const getFirefliesPlan = (): FirefliesPlan => {
|
||||
const plan = (process.env.FIREFLIES_PLAN || FIREFLIES_PLANS.FREE).toLowerCase();
|
||||
if (plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE) {
|
||||
return plan as FirefliesPlan;
|
||||
}
|
||||
if (plan === FIREFLIES_PLANS.PRO) {
|
||||
return FIREFLIES_PLANS.PRO;
|
||||
}
|
||||
return FIREFLIES_PLANS.FREE;
|
||||
};
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import { FirefliesApiClient } from './fireflies-api-client';
|
||||
import { MeetingFormatter } from './formatters';
|
||||
import { clearAllCapturedLogs, createLogger, getAllCapturedLogs } from './logger';
|
||||
import { TwentyCrmService } from './twenty-crm-service';
|
||||
import type { FirefliesWebhookPayload, ProcessResult } from './types';
|
||||
import { getApiUrl, getFirefliesPlan, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
|
||||
import {
|
||||
getWebhookSecretFingerprint,
|
||||
isValidFirefliesPayload,
|
||||
verifyWebhookSignature
|
||||
} from './webhook-validator';
|
||||
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
export class WebhookHandler {
|
||||
private debug: string[] = [];
|
||||
private logger: ReturnType<typeof createLogger>;
|
||||
|
||||
constructor() {
|
||||
this.logger = createLogger('fireflies');
|
||||
}
|
||||
|
||||
private addDebugLogs(result: ProcessResult): ProcessResult {
|
||||
if (process.env.CAPTURE_LOGS === 'true') {
|
||||
const captured = getAllCapturedLogs();
|
||||
result.debug = [...this.debug, ...captured];
|
||||
} else {
|
||||
delete result.debug;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
|
||||
clearAllCapturedLogs();
|
||||
this.debug = [];
|
||||
|
||||
const result: ProcessResult = {
|
||||
success: false,
|
||||
noteIds: [],
|
||||
newContacts: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
this.logger.debug('invoked');
|
||||
this.logger.debug(`apiUrl=${getApiUrl()}`);
|
||||
const paramKeys =
|
||||
params && typeof params === 'object'
|
||||
? Object.keys(params as Record<string, unknown>)
|
||||
: [];
|
||||
this.debug.push(
|
||||
`paramsType=${typeof params}`,
|
||||
`paramKeys=${paramKeys.join(',') || 'none'}`
|
||||
);
|
||||
|
||||
// 0) Validate environment configuration
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
this.logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY environment variable is required');
|
||||
}
|
||||
if (!twentyApiKey) {
|
||||
this.logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('TWENTY_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
// 1) Parse and validate webhook payload and extract headers if wrapped together
|
||||
const { payload, extractedHeaders } = this.parsePayload(params);
|
||||
const finalHeaders = extractedHeaders || headers;
|
||||
|
||||
this.logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
|
||||
|
||||
// 2) Verify webhook signature
|
||||
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
|
||||
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
|
||||
this.logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
|
||||
|
||||
this.verifySignature(payload, finalHeaders, webhookSecret);
|
||||
this.logger.debug('signature verification: ok');
|
||||
|
||||
// 3) Fetch meeting data from Fireflies
|
||||
const summaryConfig = getSummaryFetchConfig();
|
||||
const firefliesPlan = getFirefliesPlan();
|
||||
this.logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
|
||||
this.logger.debug(`fetching meeting data from Fireflies API`);
|
||||
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
|
||||
payload.meetingId,
|
||||
summaryConfig,
|
||||
firefliesPlan
|
||||
);
|
||||
|
||||
this.logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
|
||||
|
||||
result.summaryReady = summaryReady;
|
||||
result.summaryPending = !summaryReady;
|
||||
|
||||
// Extract business intelligence
|
||||
if (summaryReady) {
|
||||
result.actionItemsCount = meetingData.summary.action_items.length;
|
||||
result.keyTopics = meetingData.summary.topics_discussed;
|
||||
result.meetingType = meetingData.summary.meeting_type;
|
||||
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
result.sentimentAnalysis = meetingData.analytics.sentiments;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Check for duplicate meetings
|
||||
const twentyService = new TwentyCrmService(
|
||||
twentyApiKey,
|
||||
getApiUrl()
|
||||
);
|
||||
|
||||
const existingMeetingById = await twentyService.findMeetingByFirefliesId(meetingData.id);
|
||||
if (existingMeetingById) {
|
||||
this.logger.debug(`meeting already exists by firefliesMeetingId id=${existingMeetingById.id}`);
|
||||
result.success = true;
|
||||
result.meetingId = existingMeetingById.id;
|
||||
return this.addDebugLogs(result);
|
||||
}
|
||||
|
||||
const existingMeetingByTitle = await twentyService.findExistingMeeting(meetingData.title);
|
||||
if (existingMeetingByTitle) {
|
||||
this.logger.debug(`meeting already exists by title id=${existingMeetingByTitle.id}`);
|
||||
result.success = true;
|
||||
result.meetingId = existingMeetingByTitle.id;
|
||||
return this.addDebugLogs(result);
|
||||
}
|
||||
this.logger.debug('no existing meeting found, proceeding');
|
||||
|
||||
// 5) Match participants to existing contacts
|
||||
this.logger.debug(`total participants from API: ${meetingData.participants.length}`);
|
||||
meetingData.participants.forEach((p, idx) => {
|
||||
this.logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
|
||||
meetingData.participants
|
||||
);
|
||||
this.logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
|
||||
|
||||
unmatchedParticipants.forEach((p, idx) => {
|
||||
this.logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
// 6) Optionally create contacts
|
||||
const autoCreate = shouldAutoCreateContacts();
|
||||
const newContactIds = autoCreate
|
||||
? await twentyService.createContactsForUnmatched(unmatchedParticipants)
|
||||
: [];
|
||||
result.newContacts = newContactIds;
|
||||
this.logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
|
||||
|
||||
// 7) Create note first (so we can link to it from the meeting)
|
||||
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
|
||||
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
|
||||
const noteId = await twentyService.createNoteOnly(
|
||||
`Meeting: ${meetingData.title}`,
|
||||
noteBody
|
||||
);
|
||||
result.noteIds = [noteId];
|
||||
this.logger.debug(`created note id=${noteId}`);
|
||||
|
||||
// 8) Create meeting with direct relationship to the note
|
||||
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
|
||||
this.logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
|
||||
result.meetingId = await twentyService.createMeeting(meetingInput);
|
||||
this.logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
|
||||
|
||||
// 9) Link note to participants (Meeting link is handled via the relation field)
|
||||
await this.linkNoteToParticipants(
|
||||
twentyService,
|
||||
noteId,
|
||||
allContactIds
|
||||
);
|
||||
this.logger.debug(`linked note to ${allContactIds.length} participants`);
|
||||
|
||||
result.success = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`error: ${message}`);
|
||||
result.errors?.push(message);
|
||||
|
||||
// Try to create a failed meeting record for tracking
|
||||
await this.createFailedMeetingRecord(params, message);
|
||||
}
|
||||
|
||||
return this.addDebugLogs(result);
|
||||
}
|
||||
|
||||
private parsePayload(params: unknown): { payload: FirefliesWebhookPayload; extractedHeaders?: Record<string, string> } {
|
||||
let normalizedParams = params;
|
||||
let extractedHeaders: Record<string, string> | undefined;
|
||||
|
||||
// Handle string-encoded params
|
||||
if (typeof normalizedParams === 'string') {
|
||||
this.logger.debug(`received params as string length=${normalizedParams.length}`);
|
||||
try {
|
||||
const parsed = JSON.parse(normalizedParams);
|
||||
normalizedParams = parsed;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsedKeys = Object.keys(parsed as Record<string, unknown>);
|
||||
this.logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
|
||||
}
|
||||
} catch (parseError) {
|
||||
this.logger.error(`error parsing string params: ${String(parseError)}`);
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle wrapped payloads and extract headers if present
|
||||
let payload: FirefliesWebhookPayload | undefined;
|
||||
if (isValidFirefliesPayload(normalizedParams)) {
|
||||
payload = normalizedParams as FirefliesWebhookPayload;
|
||||
} else if (normalizedParams && typeof normalizedParams === 'object') {
|
||||
const wrapper = normalizedParams as Record<string, unknown>;
|
||||
|
||||
// Extract headers if present in wrapper
|
||||
if (wrapper.headers && typeof wrapper.headers === 'object' && !Array.isArray(wrapper.headers)) {
|
||||
extractedHeaders = wrapper.headers as Record<string, string>;
|
||||
const headerKeys = Object.keys(extractedHeaders);
|
||||
this.logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
|
||||
}
|
||||
|
||||
const wrapperKeys = ['params', 'payload', 'body', 'data', 'event'];
|
||||
for (const key of wrapperKeys) {
|
||||
const candidate = wrapper[key];
|
||||
if (isValidFirefliesPayload(candidate)) {
|
||||
this.logger.debug(`detected payload under wrapper key "${key}"`);
|
||||
payload = candidate as FirefliesWebhookPayload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
this.logger.error('error: Invalid or missing webhook payload');
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
|
||||
// Log payload keys for debugging
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadKeys = Object.keys(payloadRecord);
|
||||
if (payloadKeys.length > 0) {
|
||||
this.logger.debug(`payload keys: ${payloadKeys.join(',')}`);
|
||||
}
|
||||
|
||||
return { payload, extractedHeaders };
|
||||
}
|
||||
|
||||
private verifySignature(
|
||||
payload: FirefliesWebhookPayload,
|
||||
headers: Record<string, string> | undefined,
|
||||
webhookSecret: string
|
||||
): void {
|
||||
// Extract headers
|
||||
const normalizedHeaders = headers || {};
|
||||
const headerKeys = Object.keys(normalizedHeaders);
|
||||
if (headerKeys.length > 0) {
|
||||
this.logger.debug(`header keys: ${headerKeys.join(',')}`);
|
||||
}
|
||||
this.debug.push(`headerKeys=${headerKeys.join(',') || 'none'}`);
|
||||
|
||||
const headerSignature = Object.entries(normalizedHeaders).find(
|
||||
([key]) => key.toLowerCase() === 'x-hub-signature',
|
||||
)?.[1];
|
||||
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadSignature =
|
||||
typeof payloadRecord['x-hub-signature'] === 'string'
|
||||
? (payloadRecord['x-hub-signature'] as string)
|
||||
: undefined;
|
||||
|
||||
if (payloadSignature) {
|
||||
this.logger.debug('found signature inside payload');
|
||||
}
|
||||
|
||||
const signature =
|
||||
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
|
||||
|
||||
const payloadForSignature =
|
||||
payloadSignature && 'x-hub-signature' in payloadRecord
|
||||
? Object.fromEntries(
|
||||
Object.entries(payloadRecord).filter(
|
||||
([key]) => key.toLowerCase() !== 'x-hub-signature',
|
||||
),
|
||||
)
|
||||
: payloadRecord;
|
||||
|
||||
const body =
|
||||
typeof normalizedHeaders['body'] === 'string'
|
||||
? normalizedHeaders['body']
|
||||
: JSON.stringify(payloadForSignature);
|
||||
|
||||
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
|
||||
if (!signatureCheck.isValid) {
|
||||
this.debug.push(
|
||||
`signatureProvided=${Boolean(signature)}`,
|
||||
`signatureMatched=${signatureCheck.isValid}`,
|
||||
`webhookSecretFingerprint=${getWebhookSecretFingerprint(webhookSecret)}`
|
||||
);
|
||||
this.logger.debug(
|
||||
`signature check failed. headerPresent=${Boolean(
|
||||
headerSignature,
|
||||
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
|
||||
);
|
||||
this.logger.debug(`provided signature present=${Boolean(signature)}`);
|
||||
this.logger.critical('Invalid webhook signature - potential security threat detected in production');
|
||||
throw new Error('Invalid webhook signature');
|
||||
}
|
||||
}
|
||||
|
||||
private async linkNoteToParticipants(
|
||||
twentyService: TwentyCrmService,
|
||||
noteId: string,
|
||||
contactIds: string[]
|
||||
): Promise<void> {
|
||||
for (const contactId of contactIds) {
|
||||
try {
|
||||
await twentyService.createNoteTarget(noteId, contactId);
|
||||
this.logger.debug(`linked note ${noteId} to person ${contactId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`failed to link note to person ${contactId}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
|
||||
try {
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
if (!twentyApiKey) {
|
||||
this.logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
let meetingId = 'unknown';
|
||||
let meetingTitle = 'Unknown Meeting';
|
||||
|
||||
const { payload } = this.parsePayload(params);
|
||||
if (payload?.meetingId) {
|
||||
meetingId = payload.meetingId;
|
||||
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
if (firefliesApiKey) {
|
||||
try {
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
|
||||
meetingTitle = meetingData.title || meetingTitle;
|
||||
} catch (fetchError) {
|
||||
this.logger.debug(
|
||||
`Could not fetch meeting title: ${
|
||||
fetchError instanceof Error ? fetchError.message : 'Unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
|
||||
const failedMeetingData = MeetingFormatter.toFailedMeetingCreateInput(
|
||||
meetingId,
|
||||
meetingTitle,
|
||||
error
|
||||
);
|
||||
|
||||
const failedMeetingId = await twentyService.createFailedMeeting(failedMeetingData);
|
||||
this.logger.debug(`Created failed meeting record: ${failedMeetingId}`);
|
||||
} catch (recordError) {
|
||||
this.logger.error(
|
||||
`Failed to create failed meeting record: ${
|
||||
recordError instanceof Error ? recordError.message : 'Unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user