Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7ed823d3e | ||
|
|
37200a1c3b | ||
|
|
bc8c9eabeb | ||
|
|
c415660640 | ||
|
|
ad75662919 | ||
|
|
aa7c5a778b | ||
|
|
8d43ed4515 | ||
|
|
3d1d3d9f04 | ||
|
|
b19dc703cc | ||
|
|
9b668619c5 | ||
|
|
e7c83dc04f | ||
|
|
693fe01fa4 | ||
|
|
c616713435 | ||
|
|
859c948d01 |
@@ -12,8 +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)
|
||||
- **creating-syncable-entity.mdc** - Comprehensive guide for creating new syncable entities (with universalIdentifier and applicationId) in the workspace migration system (Agent-requested for metadata-modules and workspace-migration files)
|
||||
|
||||
### Code Quality
|
||||
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
|
||||
@@ -40,10 +38,9 @@ You can manually reference any rule using the `@ruleName` syntax:
|
||||
- `@nx-rules` - Include Nx-specific guidance
|
||||
- `@react-general-guidelines` - Load React best practices
|
||||
- `@testing-guidelines` - Get testing recommendations
|
||||
- `@creating-syncable-entity` - Guide for creating new syncable entities
|
||||
|
||||
### 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
|
||||
@@ -55,12 +52,10 @@ You can manually reference any rule using the `@ruleName` syntax:
|
||||
# Testing
|
||||
npx nx test twenty-front # Run unit tests
|
||||
npx nx storybook:build twenty-front # Build Storybook
|
||||
npx nx storybook:test # Run Storybook tests
|
||||
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
|
||||
```
|
||||
@@ -74,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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -1,23 +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
|
||||
"base_path": ".."
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,45 +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"
|
||||
"base_path": ".."
|
||||
|
||||
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",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,8 +2,6 @@ version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
exclude-paths:
|
||||
- "packages/twenty-apps/community/**"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
|
||||
+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/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:cli
|
||||
tasks: ${{ matrix.task }}
|
||||
cli-e2e-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: [changed-files-check, cli-test]
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
NODE_ENV: test
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Server / Append billing config to .env.test
|
||||
working-directory: packages/twenty-server
|
||||
run: |
|
||||
echo "" >> .env.test
|
||||
echo "IS_BILLING_ENABLED=true" >> .env.test
|
||||
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
|
||||
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Server / Create Test DB
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
- name: CLI / Run E2E Tests
|
||||
run: npx nx test:e2e twenty-cli
|
||||
ci-cli-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, cli-test, cli-e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -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/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/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
|
||||
@@ -25,14 +25,14 @@ jobs:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
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: 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
|
||||
+31
-193
@@ -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-ubuntu-latest-8-cores-runner
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-ubuntu-latest-8-cores-runner-${{ github.ref_name }}-${{ github.sha }}
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
|
||||
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,19 +28,11 @@ jobs:
|
||||
packages/twenty-front/**
|
||||
packages/twenty-ui/**
|
||||
packages/twenty-shared/**
|
||||
packages/twenty-sdk/**
|
||||
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'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
REACT_APP_SERVER_BASE_URL: http://localhost:3000
|
||||
steps:
|
||||
@@ -52,7 +45,7 @@ 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
|
||||
@@ -60,12 +53,12 @@ jobs:
|
||||
- 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:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: front-sb-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -81,29 +74,19 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build dependencies
|
||||
run: |
|
||||
npx nx build twenty-shared
|
||||
npx nx build twenty-ui
|
||||
npx nx build twenty-sdk
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
cd packages/twenty-front
|
||||
npx playwright install
|
||||
run: cd packages/twenty-front && npx playwright install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Run storybook tests
|
||||
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
|
||||
run: npx nx storybook:serve-and-test:static twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }} --checkCoverage=false
|
||||
- name: Rename coverage file
|
||||
run: |
|
||||
if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
|
||||
mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
|
||||
else
|
||||
echo "Error: coverage-final.json not found"
|
||||
ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
|
||||
exit 1
|
||||
fi
|
||||
run: mv packages/twenty-front/coverage/storybook/coverage-storybook.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -124,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 }}-*
|
||||
@@ -138,9 +121,9 @@ 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: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
|
||||
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
@@ -149,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
|
||||
@@ -182,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: ubuntu-latest-8-cores
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/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
|
||||
@@ -346,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,
|
||||
@@ -355,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,114 +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:unit, storybook:build, storybook:test]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build twenty-sdk
|
||||
- name: Install Playwright
|
||||
if: contains(matrix.task, 'storybook')
|
||||
run: npx playwright install chromium
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:sdk
|
||||
tasks: ${{ matrix.task }}
|
||||
# TODO uncomment when syncApplication resolver is fixed
|
||||
# sdk-e2e-integration-test:
|
||||
# timeout-minutes: 30
|
||||
# runs-on: ubuntu-latest-8-cores
|
||||
# needs: [changed-files-check, sdk-test]
|
||||
# strategy:
|
||||
# matrix:
|
||||
# task: [test:integration, test:e2e]
|
||||
# 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 ${{ matrix.task }} Tests
|
||||
# uses: ./.github/actions/nx-affected
|
||||
# with:
|
||||
# tag: scope:sdk
|
||||
# tasks: ${{ matrix.task }}
|
||||
ci-sdk-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, sdk-test]
|
||||
# TODO uncomment when syncApplication resolver is fixed
|
||||
# needs: [changed-files-check, sdk-test, sdk-e2e-integration-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
|
||||
@@ -31,7 +33,7 @@ jobs:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
@@ -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,12 +140,12 @@ 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:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: server-setup
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
@@ -151,25 +153,25 @@ 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
|
||||
|
||||
server-integration-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: server-setup
|
||||
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";'
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
repository_dispatch:
|
||||
types: [claude-core-team-issues]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.client_payload.issue_number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.user.type != 'Bot') ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Run Claude Code
|
||||
id: claude-code
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
|
||||
settings: |
|
||||
{
|
||||
"env": {
|
||||
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
|
||||
}
|
||||
}
|
||||
- name: Post Create-PR link if Claude ran out of turns
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH=$(git branch --show-current)
|
||||
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "" ]; then
|
||||
exit 0
|
||||
fi
|
||||
AHEAD=$(git rev-list --count main.."$BRANCH" 2>/dev/null || echo "0")
|
||||
if [ "$AHEAD" = "0" ]; then
|
||||
exit 0
|
||||
fi
|
||||
EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
exit 0
|
||||
fi
|
||||
ISSUE_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}"
|
||||
ENCODED_BRANCH=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$BRANCH', safe=''))")
|
||||
PR_URL="https://github.com/${{ github.repository }}/compare/main...${ENCODED_BRANCH}?quick_pull=1"
|
||||
BODY="⚠️ Claude ran out of turns before creating a PR. Work has been pushed to [\`$BRANCH\`](https://github.com/${{ github.repository }}/tree/$ENCODED_BRANCH).\n\n[**Create PR →**]($PR_URL)"
|
||||
if [ -n "$ISSUE_NUMBER" ]; then
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$(echo -e "$BODY")"
|
||||
fi
|
||||
|
||||
claude-cross-repo:
|
||||
if: github.event_name == 'repository_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
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
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build prompt from dispatch payload
|
||||
id: prompt
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const p = context.payload.client_payload;
|
||||
let prompt;
|
||||
if (p.comment_body) {
|
||||
prompt = `You are responding to a comment on issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository.\n\nThe comment by @${p.sender} says:\n\n${p.comment_body}\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
|
||||
} else {
|
||||
prompt = `You are responding to issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository, opened by @${p.sender}.\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
|
||||
}
|
||||
core.setOutput('prompt', prompt);
|
||||
core.setOutput('repo', p.repo_full_name);
|
||||
core.setOutput('issue_number', p.issue_number);
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: ${{ steps.prompt.outputs.prompt }}
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
|
||||
settings: |
|
||||
{
|
||||
"env": {
|
||||
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
|
||||
}
|
||||
}
|
||||
- name: Post response to source issue
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.TWENTY_DISPATCH_TOKEN }}
|
||||
script: |
|
||||
const [owner, repo] = '${{ steps.prompt.outputs.repo }}'.split('/');
|
||||
const issueNumber = parseInt('${{ steps.prompt.outputs.issue_number }}', 10);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `Claude finished processing this request. [See workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
|
||||
});
|
||||
@@ -24,7 +24,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/twenty-docs/**'
|
||||
- '.github/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 .github/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
|
||||
@@ -107,21 +115,16 @@ jobs:
|
||||
- name: Regenerate docs.json
|
||||
run: yarn docs:generate
|
||||
|
||||
- name: Regenerate documentation paths constants
|
||||
run: yarn docs:generate-paths
|
||||
|
||||
- name: Commit artifacts to pull request branch
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json packages/twenty-shared/src/constants/DocumentationPaths.ts
|
||||
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json
|
||||
if git diff --staged --quiet --exit-code; then
|
||||
echo "No navigation/doc changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: sync docs artifacts"
|
||||
git push origin "HEAD:$HEAD_REF"
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
|
||||
- name: Check for changes and commit
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -137,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'
|
||||
- '.github/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: '.github/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: '.github/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 'github-actions@twenty.com'
|
||||
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: '.github/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 'github-actions@twenty.com'
|
||||
|
||||
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
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
"mcpServers": {
|
||||
"postgres": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
|
||||
"env": {}
|
||||
"command": "uv",
|
||||
"args": ["run", "postgres-mcp", "--access-mode=unrestricted"],
|
||||
"env": {
|
||||
"DATABASE_URI": "${PG_DATABASE_URL}"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Add files here to ignore them from prettier formatting
|
||||
/dist
|
||||
/coverage
|
||||
/.nx/cache
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
Vendored
+2
-13
@@ -67,15 +67,13 @@
|
||||
"--config",
|
||||
"./jest-integration.config.ts",
|
||||
"${relativeFile}",
|
||||
"--silent=false",
|
||||
"${input:updateSnapshot}"
|
||||
"--silent=false"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/packages/twenty-server",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"env": {
|
||||
"NODE_ENV": "test",
|
||||
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
|
||||
"NODE_ENV": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -99,14 +97,5 @@
|
||||
"NODE_ENV": "test"
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "updateSnapshot",
|
||||
"type": "pickString",
|
||||
"description": "Update snapshots?",
|
||||
"options": ["", "--updateSnapshot"],
|
||||
"default": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
-3
@@ -31,7 +31,6 @@
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"javascript.format.enable": false,
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.format.enable": false,
|
||||
"cSpell.enableFiletypes": [
|
||||
"!javascript",
|
||||
@@ -53,6 +52,5 @@
|
||||
".cursorrules": "markdown"
|
||||
},
|
||||
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.experimental.useTsgo": false
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
Vendored
+7
-59
@@ -2,64 +2,12 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "twenty-server - run integration test file",
|
||||
"type": "shell",
|
||||
"command": "npx nx run twenty-server:jest -- --config ./jest-integration.config.ts ${relativeFile} --silent=false ${input:watchMode} ${input:updateSnapshot}",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/packages/twenty-server",
|
||||
"env": {
|
||||
"NODE_ENV": "test",
|
||||
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
|
||||
},
|
||||
"shell": {
|
||||
"executable": "/bin/zsh",
|
||||
"args": ["-l", "-c"]
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new",
|
||||
"close": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "twenty-server - run unit test file",
|
||||
"type": "shell",
|
||||
"command": "npx nx run twenty-server:jest -- --config ./jest.config.mjs ${relativeFile} --silent=false ${input:watchMode} ${input:updateSnapshot}",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/packages/twenty-server",
|
||||
"env": {
|
||||
"NODE_ENV": "test",
|
||||
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
|
||||
},
|
||||
"shell": {
|
||||
"executable": "/bin/zsh",
|
||||
"args": ["-l", "-c"]
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new",
|
||||
"close": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "watchMode",
|
||||
"type": "pickString",
|
||||
"description": "Enable watch mode?",
|
||||
"options": ["", "--watch"],
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "updateSnapshot",
|
||||
"type": "pickString",
|
||||
"description": "Update snapshots?",
|
||||
"options": ["", "--updateSnapshot"],
|
||||
"default": ""
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"path": "server",
|
||||
"problemMatcher": [],
|
||||
"label": "yarn: start - server",
|
||||
"detail": "yarn start"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -21,31 +21,25 @@ npx nx run twenty-server:worker # Start background worker
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Preferred: run a single test file (fast)
|
||||
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
|
||||
|
||||
# Run all tests for a package
|
||||
# Run tests
|
||||
npx nx test twenty-front # Frontend unit tests
|
||||
npx nx test twenty-server # Backend unit tests
|
||||
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
|
||||
|
||||
# Storybook
|
||||
npx nx storybook:build twenty-front
|
||||
npx nx storybook:test twenty-front
|
||||
npx nx storybook:build twenty-front # Build Storybook
|
||||
npx nx storybook:serve-and-test:static twenty-front # Run Storybook tests
|
||||
|
||||
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
|
||||
|
||||
When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Linting (diff with main - fastest, always prefer this)
|
||||
npx nx lint:diff-with-main twenty-front
|
||||
npx nx lint:diff-with-main twenty-server
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
|
||||
|
||||
# Linting (full project - slower, use only when needed)
|
||||
npx nx lint twenty-front
|
||||
npx nx lint twenty-server
|
||||
# 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
|
||||
@@ -58,8 +52,7 @@ npx nx fmt twenty-server
|
||||
|
||||
### Build
|
||||
```bash
|
||||
# Build packages (twenty-shared must be built first)
|
||||
npx nx build twenty-shared
|
||||
# Build packages
|
||||
npx nx build twenty-front
|
||||
npx nx build twenty-server
|
||||
```
|
||||
@@ -71,7 +64,7 @@ npx nx database:reset twenty-server # Reset database
|
||||
npx nx run twenty-server:database:init:prod # Initialize database
|
||||
npx nx run twenty-server:database:migrate:prod # Run migrations
|
||||
|
||||
# Generate migration (replace [name] with kebab-case descriptive name)
|
||||
# Generate migration
|
||||
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
|
||||
|
||||
# Sync metadata
|
||||
@@ -80,9 +73,8 @@ npx nx run twenty-server:command workspace:sync-metadata
|
||||
|
||||
### GraphQL
|
||||
```bash
|
||||
# Generate GraphQL types (run after schema changes)
|
||||
# Generate GraphQL types
|
||||
npx nx run twenty-front:graphql:generate
|
||||
npx nx run twenty-front:graphql:generate --configuration=metadata
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
@@ -110,36 +102,13 @@ packages/
|
||||
- **Named exports only** (no default exports)
|
||||
- **Types over interfaces** (except when extending third-party interfaces)
|
||||
- **String literals over enums** (except for GraphQL enums)
|
||||
- **No 'any' type allowed** — strict TypeScript enforced
|
||||
- **No 'any' type allowed**
|
||||
- **Event handlers preferred over useEffect** for state updates
|
||||
- **Props down, events up** — unidirectional data flow
|
||||
- **Composition over inheritance**
|
||||
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
|
||||
|
||||
### Naming Conventions
|
||||
- **Variables/functions**: camelCase
|
||||
- **Constants**: SCREAMING_SNAKE_CASE
|
||||
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
|
||||
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
|
||||
- **TypeScript generics**: descriptive names (`TData` not `T`)
|
||||
|
||||
### File Structure
|
||||
- Components under 300 lines, services under 500 lines
|
||||
- Components in their own directories with tests and stories
|
||||
- Use `index.ts` barrel exports for clean imports
|
||||
- Import order: external libraries first, then internal (`@/`), then relative
|
||||
|
||||
### Comments
|
||||
- Use short-form comments (`//`), not JSDoc blocks
|
||||
- Explain WHY (business logic), not WHAT
|
||||
- Do not comment obvious code
|
||||
- Multi-line comments use multiple `//` lines, not `/** */`
|
||||
|
||||
### State Management
|
||||
- **Recoil** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
|
||||
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
|
||||
- **Recoil** for global state management
|
||||
- Component-specific state with React hooks
|
||||
- GraphQL cache managed by Apollo Client
|
||||
- Use functional state updates: `setState(prev => prev + 1)`
|
||||
|
||||
### Backend Architecture
|
||||
- **NestJS modules** for feature organization
|
||||
@@ -148,54 +117,36 @@ packages/
|
||||
- **Redis** for caching and session management
|
||||
- **BullMQ** for background job processing
|
||||
|
||||
### Database & Migrations
|
||||
### Database
|
||||
- **PostgreSQL** as primary database
|
||||
- **Redis** for caching and sessions
|
||||
- **TypeORM migrations** for schema management
|
||||
- **ClickHouse** for analytics (when enabled)
|
||||
- Always generate migrations when changing entity files
|
||||
- Migration names must be kebab-case (e.g. `add-agent-turn-evaluation`)
|
||||
- Include both `up` and `down` logic in migrations
|
||||
- Never delete or rewrite committed migrations
|
||||
|
||||
### Utility Helpers
|
||||
Use existing helpers from `twenty-shared` instead of manual type guards:
|
||||
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
|
||||
|
||||
### Before Making Changes
|
||||
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
|
||||
2. Test changes with relevant test suites (prefer single-file test runs)
|
||||
3. Ensure database migrations are generated for entity changes
|
||||
1. Always run linting and type checking after code changes
|
||||
2. Test changes with relevant test suites
|
||||
3. Ensure database migrations are properly structured
|
||||
4. Check that GraphQL schema changes are backward compatible
|
||||
5. Run `graphql:generate` after any GraphQL schema changes
|
||||
|
||||
### Code Style Notes
|
||||
- Use **Emotion** for styling with styled-components pattern
|
||||
- Follow **Nx** workspace conventions for imports
|
||||
- Use **Lingui** for internationalization
|
||||
- Apply security first, then formatting (sanitize before format)
|
||||
- Components should be in their own directories with tests and stories
|
||||
|
||||
### Testing Strategy
|
||||
- **Test behavior, not implementation** — focus on user perspective
|
||||
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
|
||||
- Query by user-visible elements (text, roles, labels) over test IDs
|
||||
- Use `@testing-library/user-event` for realistic interactions
|
||||
- Descriptive test names: "should [behavior] when [condition]"
|
||||
- Clear mocks between tests with `jest.clearAllMocks()`
|
||||
|
||||
## CI Environment (GitHub Actions)
|
||||
|
||||
When running in CI, the dev environment is **not** pre-configured. Dependencies are installed but builds, env files, and databases are not set up.
|
||||
|
||||
- **Before running tests, builds, lint, type checks, or DB operations**, run: `bash packages/twenty-utils/setup-dev-env.sh`
|
||||
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
|
||||
- The script is idempotent and safe to run multiple times.
|
||||
- **Unit tests** with Jest for both frontend and backend
|
||||
- **Integration tests** for critical backend workflows
|
||||
- **Storybook** for component development and testing
|
||||
- **E2E tests** with Playwright for critical user flows
|
||||
|
||||
## Important Files
|
||||
- `nx.json` - Nx workspace configuration with task definitions
|
||||
- `tsconfig.base.json` - Base TypeScript configuration
|
||||
- `package.json` - Root package with workspace definitions
|
||||
- `.cursor/rules/` - Detailed development guidelines and best practices
|
||||
- `.cursor/rules/` - Development guidelines and best practices
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
DOCKER_NETWORK=twenty_network
|
||||
|
||||
ensure-docker-network:
|
||||
docker network inspect $(DOCKER_NETWORK) >/dev/null 2>&1 || docker network create $(DOCKER_NETWORK)
|
||||
|
||||
postgres-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) \
|
||||
--name twenty_pg \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e ALLOW_NOSSL=true \
|
||||
-v twenty_db_data:/var/lib/postgresql/data \
|
||||
-p 5432:5432 \
|
||||
postgres:16
|
||||
@echo "Waiting for PostgreSQL to be ready..."
|
||||
@until docker exec twenty_pg psql -U postgres -d postgres \
|
||||
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
|
||||
sleep 1; \
|
||||
done
|
||||
docker exec twenty_pg psql -U postgres -d postgres \
|
||||
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
|
||||
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
|
||||
|
||||
redis-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
|
||||
|
||||
clickhouse-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest \
|
||||
|
||||
grafana-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) \
|
||||
--name twenty_grafana \
|
||||
-p 4000:3000 \
|
||||
-e GF_SECURITY_ADMIN_USER=admin \
|
||||
-e GF_SECURITY_ADMIN_PASSWORD=admin \
|
||||
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
|
||||
-v $(PWD)/packages/twenty-docker/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources \
|
||||
grafana/grafana-oss:latest
|
||||
|
||||
opentelemetry-collector-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) \
|
||||
--name twenty_otlp_collector \
|
||||
-p 4317:4317 \
|
||||
-p 4318:4318 \
|
||||
-p 13133:13133 \
|
||||
-v $(PWD)/packages/twenty-docker/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml \
|
||||
otel/opentelemetry-collector-contrib:latest \
|
||||
--config /etc/otel-collector-config.yaml
|
||||
+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",
|
||||
}
|
||||
]
|
||||
+8
-13
@@ -11,10 +11,6 @@ import unicornPlugin from 'eslint-plugin-unicorn';
|
||||
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
|
||||
import jsoncParser from 'jsonc-eslint-parser';
|
||||
|
||||
const twentyRules = await nxPlugin.loadWorkspaceRules(
|
||||
'packages/twenty-eslint-rules',
|
||||
);
|
||||
|
||||
export default [
|
||||
// Base JavaScript configuration
|
||||
js.configs.recommended,
|
||||
@@ -67,10 +63,6 @@ export default [
|
||||
sourceTag: 'scope:sdk',
|
||||
onlyDependOnLibsWithTags: ['scope:sdk', 'scope:shared'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:create-app',
|
||||
onlyDependOnLibsWithTags: ['scope:create-app', 'scope:shared'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:shared',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
@@ -83,6 +75,10 @@ export default [
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -145,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',
|
||||
@@ -203,7 +199,6 @@ export default [
|
||||
plugins: {
|
||||
...mdxPlugin.flat.plugins,
|
||||
'@nx': nxPlugin,
|
||||
twenty: { rules: twentyRules },
|
||||
},
|
||||
},
|
||||
mdxPlugin.flatCodeBlocks,
|
||||
@@ -214,9 +209,9 @@ export default [
|
||||
'unused-imports/no-unused-imports': 'off',
|
||||
'unused-imports/no-unused-vars': 'off',
|
||||
// Enforce JSX tags on separate lines to prevent Crowdin translation issues
|
||||
'twenty/mdx-component-newlines': 'error',
|
||||
'@nx/workspace-mdx-component-newlines': 'error',
|
||||
// Disallow angle bracket placeholders to prevent Crowdin translation errors
|
||||
'twenty/no-angle-bracket-placeholders': 'error',
|
||||
'@nx/workspace-no-angle-bracket-placeholders': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import js from '@eslint/js';
|
||||
import nxPlugin from '@nx/eslint-plugin';
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import linguiPlugin from 'eslint-plugin-lingui';
|
||||
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
|
||||
import prettierPlugin from 'eslint-plugin-prettier';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
|
||||
import unicornPlugin from 'eslint-plugin-unicorn';
|
||||
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
|
||||
import jsoncParser from 'jsonc-eslint-parser';
|
||||
|
||||
export default [
|
||||
// Base JavaScript configuration
|
||||
js.configs.recommended,
|
||||
|
||||
// Lingui recommended rules
|
||||
linguiPlugin.configs['flat/recommended'],
|
||||
|
||||
// Base configuration for all files
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
'react': reactPlugin,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
'react-refresh': reactRefreshPlugin,
|
||||
'prettier': prettierPlugin,
|
||||
'lingui': linguiPlugin,
|
||||
'@nx': nxPlugin,
|
||||
'prefer-arrow': preferArrowPlugin,
|
||||
'import': importPlugin,
|
||||
'unused-imports': unusedImportsPlugin,
|
||||
'unicorn': unicornPlugin,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// General rules
|
||||
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
|
||||
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
|
||||
'no-control-regex': 0,
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// Nx rules
|
||||
'@nx/enforce-module-boundaries': [
|
||||
'error',
|
||||
{
|
||||
enforceBuildableLibDependency: true,
|
||||
allow: [],
|
||||
depConstraints: [
|
||||
{
|
||||
sourceTag: 'scope:shared',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:backend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// Import rules
|
||||
'import/no-relative-packages': 'error',
|
||||
'import/no-useless-path-segments': 'error',
|
||||
'import/no-duplicates': ['error', { considerQueryString: true }],
|
||||
|
||||
// Prefer arrow functions
|
||||
'prefer-arrow/prefer-arrow-functions': [
|
||||
'error',
|
||||
{
|
||||
disallowPrototype: true,
|
||||
singleReturnOnly: false,
|
||||
classPropertiesAllowed: false,
|
||||
},
|
||||
],
|
||||
|
||||
// Unused imports
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
// React rules
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react/jsx-key': 'off',
|
||||
'react/display-name': 'off',
|
||||
'react/jsx-uses-react': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/jsx-no-useless-fragment': 'off',
|
||||
'react/jsx-props-no-spreading': [
|
||||
'error',
|
||||
{
|
||||
explicitSpread: 'ignore',
|
||||
},
|
||||
],
|
||||
|
||||
// React hooks rules
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: 'useRecoilCallback',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// TypeScript specific configuration
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
// Note: project path should be specified by each package individually
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
},
|
||||
rules: {
|
||||
// Import restrictions
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@tabler/icons-react'],
|
||||
message: 'Please import icons from `twenty-ui`',
|
||||
},
|
||||
{
|
||||
group: ['react-hotkeys-web-hook'],
|
||||
importNames: ['useHotkeys'],
|
||||
message: 'Please use the custom wrapper: `useScopedHotkeys` from `twenty-ui`',
|
||||
},
|
||||
{
|
||||
group: ['lodash'],
|
||||
message: "Please use the standalone lodash package (for instance: `import groupBy from 'lodash.groupby'` instead of `import { groupBy } from 'lodash'`)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// TypeScript rules
|
||||
'no-redeclare': 'off', // Turn off base rule for TypeScript
|
||||
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
|
||||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports'
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
// Custom workspace rules
|
||||
'@nx/workspace-effect-components': 'error',
|
||||
'@nx/workspace-no-hardcoded-colors': 'error',
|
||||
'@nx/workspace-matching-state-variable': 'error',
|
||||
'@nx/workspace-sort-css-properties-alphabetically': 'error',
|
||||
'@nx/workspace-styled-components-prefixed-with-styled': 'error',
|
||||
'@nx/workspace-no-state-useref': 'error',
|
||||
'@nx/workspace-component-props-naming': 'error',
|
||||
'@nx/workspace-explicit-boolean-predicates-in-if': 'error',
|
||||
'@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error',
|
||||
'@nx/workspace-useRecoilCallback-has-dependency-array': 'error',
|
||||
'@nx/workspace-no-navigate-prefer-link': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// Storybook files
|
||||
{
|
||||
files: ['*.stories.@(ts|tsx|js|jsx)'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// JavaScript specific configuration
|
||||
{
|
||||
files: ['*.{js,jsx}'],
|
||||
rules: {
|
||||
// JavaScript-specific rules if needed
|
||||
},
|
||||
},
|
||||
|
||||
// Constants files
|
||||
{
|
||||
files: ['**/constants/*.ts', '**/*.constants.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['UPPER_CASE'],
|
||||
},
|
||||
],
|
||||
'unicorn/filename-case': [
|
||||
'warn',
|
||||
{
|
||||
cases: {
|
||||
pascalCase: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
|
||||
// Test files
|
||||
{
|
||||
files: [
|
||||
'*.test.@(ts|tsx|js|jsx)',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
jest: true,
|
||||
describe: true,
|
||||
it: true,
|
||||
expect: true,
|
||||
beforeEach: true,
|
||||
afterEach: true,
|
||||
beforeAll: true,
|
||||
afterAll: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Constants files
|
||||
{
|
||||
files: ['**/*.constants.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['UPPER_CASE'],
|
||||
},
|
||||
],
|
||||
'unicorn/filename-case': [
|
||||
'warn',
|
||||
{
|
||||
cases: {
|
||||
pascalCase: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
|
||||
// JSON files
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
languageOptions: {
|
||||
parser: jsoncParser,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getJestProjects } from '@nx/jest';
|
||||
|
||||
export default {
|
||||
projects: getJestProjects(),
|
||||
};
|
||||
+1
-7
@@ -1,9 +1,3 @@
|
||||
const nxPreset = require('@nx/jest/preset').default;
|
||||
|
||||
module.exports = {
|
||||
...nxPreset,
|
||||
// Override the new testEnvironmentOptions added in @nx/jest 22.3.3
|
||||
// which breaks Lingui's module resolution
|
||||
testEnvironmentOptions: {},
|
||||
};
|
||||
|
||||
module.exports = { ...nxPreset };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
command -v node >/dev/null 2>&1 || { echo >&2 "Nx requires NodeJS to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
|
||||
command -v npm >/dev/null 2>&1 || { echo >&2 "Nx requires npm to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
|
||||
path_to_root=$(dirname $BASH_SOURCE)
|
||||
node $path_to_root/.nx/nxw.js $@
|
||||
@@ -58,19 +58,6 @@
|
||||
},
|
||||
"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' _"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
@@ -97,11 +84,11 @@
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "tsgo -p tsconfig.json"
|
||||
"command": "tsc -b tsconfig.json --incremental"
|
||||
},
|
||||
"configurations": {
|
||||
"watch": {
|
||||
"command": "tsgo -p tsconfig.json --watch"
|
||||
"watch": true
|
||||
}
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
@@ -146,7 +133,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -181,8 +168,15 @@
|
||||
"outputs": ["{projectRoot}/coverage/storybook"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "vitest run --coverage --shard={args.shard}",
|
||||
"shard": "1/1"
|
||||
"commands": [
|
||||
"test-storybook --url http://localhost:{args.port} --maxWorkers=3 --coverage --coverageDirectory={args.coverageDir} --shard={args.shard}",
|
||||
"nx storybook:coverage {projectName} --coverageDir={args.coverageDir} --checkCoverage={args.checkCoverage}"
|
||||
],
|
||||
"shard": "1/1",
|
||||
"parallel": false,
|
||||
"coverageDir": "coverage/storybook",
|
||||
"port": 6006,
|
||||
"checkCoverage": true
|
||||
}
|
||||
},
|
||||
"storybook:test:no-coverage": {
|
||||
@@ -190,8 +184,10 @@
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "vitest run --shard={args.shard}",
|
||||
"shard": "1/1"
|
||||
"commands": [
|
||||
"test-storybook --url http://localhost:{args.port} --maxWorkers=2"
|
||||
],
|
||||
"port": 6006
|
||||
}
|
||||
},
|
||||
"storybook:coverage": {
|
||||
@@ -218,6 +214,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"storybook:serve-and-test:static": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"commands": [
|
||||
"npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --shard={args.shard} --checkCoverage={args.checkCoverage} --port={args.port} --configuration={args.scope}'"
|
||||
],
|
||||
"shard": "1/1",
|
||||
"checkCoverage": true,
|
||||
"port": 6006
|
||||
}
|
||||
},
|
||||
"chromatic": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
@@ -259,21 +266,21 @@
|
||||
"inputs": [
|
||||
"default",
|
||||
"{workspaceRoot}/eslint.config.mjs",
|
||||
"{workspaceRoot}/packages/twenty-eslint-rules/**/*"
|
||||
"{workspaceRoot}/tools/eslint-rules/**/*"
|
||||
]
|
||||
},
|
||||
"@nx/vite:test": {
|
||||
"cache": true,
|
||||
"inputs": ["default", "^default"]
|
||||
},
|
||||
"@nx/vite:build": {
|
||||
"cache": true,
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["default", "^default"]
|
||||
},
|
||||
"@nx/vitest:test": {
|
||||
"cache": true,
|
||||
"inputs": ["default", "^default"]
|
||||
}
|
||||
},
|
||||
"installation": {
|
||||
"version": "22.3.3"
|
||||
"version": "22.0.3"
|
||||
},
|
||||
"generators": {
|
||||
"@nx/react": {
|
||||
|
||||
+41
-42
@@ -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,36 +69,41 @@
|
||||
"@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",
|
||||
"@graphql-codegen/typescript-operations": "^3.0.4",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||
"@nx/eslint": "22.3.3",
|
||||
"@nx/eslint-plugin": "22.3.3",
|
||||
"@nx/jest": "22.3.3",
|
||||
"@nx/js": "22.3.3",
|
||||
"@nx/react": "22.3.3",
|
||||
"@nx/storybook": "22.3.3",
|
||||
"@nx/vite": "22.3.3",
|
||||
"@nx/web": "22.3.3",
|
||||
"@nx/eslint": "22.0.3",
|
||||
"@nx/eslint-plugin": "22.0.3",
|
||||
"@nx/jest": "22.0.3",
|
||||
"@nx/js": "22.0.3",
|
||||
"@nx/react": "22.0.3",
|
||||
"@nx/storybook": "22.0.3",
|
||||
"@nx/vite": "22.0.3",
|
||||
"@nx/web": "22.0.3",
|
||||
"@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/addon-vitest": "^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",
|
||||
@@ -103,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",
|
||||
@@ -135,11 +144,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.39.0",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"@typescript-eslint/utils": "^8.39.0",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
|
||||
"@vitejs/plugin-react-swc": "3.11.0",
|
||||
"@vitest/browser-playwright": "^4.0.17",
|
||||
"@vitest/coverage-istanbul": "^4.0.17",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"@yarnpkg/types": "^4.0.0",
|
||||
"chromatic": "^6.18.0",
|
||||
"concurrently": "^8.2.2",
|
||||
@@ -159,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",
|
||||
@@ -168,16 +173,16 @@
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jsdom": "~22.1.0",
|
||||
"msw": "^2.12.7",
|
||||
"msw-storybook-addon": "^2.0.6",
|
||||
"nx": "22.3.3",
|
||||
"msw": "^2.0.11",
|
||||
"msw-storybook-addon": "^2.0.5",
|
||||
"nx": "22.0.3",
|
||||
"prettier": "^3.1.1",
|
||||
"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",
|
||||
@@ -185,7 +190,10 @@
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.17.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.0.17"
|
||||
"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",
|
||||
@@ -201,16 +209,13 @@
|
||||
"typescript": "5.9.2",
|
||||
"graphql-redis-subscriptions/ioredis": "^5.6.0",
|
||||
"prosemirror-view": "1.40.0",
|
||||
"prosemirror-transform": "1.10.4",
|
||||
"@lingui/core": "5.1.2",
|
||||
"@types/qs": "6.9.16"
|
||||
"prosemirror-transform": "1.10.4"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"nx": {},
|
||||
"scripts": {
|
||||
"docs:generate": "tsx packages/twenty-docs/scripts/generate-docs-json.ts",
|
||||
"docs:generate-navigation-template": "tsx packages/twenty-docs/scripts/generate-navigation-template.ts",
|
||||
"docs:generate-paths": "tsx packages/twenty-docs/scripts/generate-documentation-paths.ts",
|
||||
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
|
||||
},
|
||||
"workspaces": {
|
||||
@@ -228,13 +233,7 @@
|
||||
"packages/twenty-sdk",
|
||||
"packages/twenty-apps",
|
||||
"packages/twenty-cli",
|
||||
"packages/create-twenty-app",
|
||||
"packages/twenty-eslint-rules"
|
||||
"tools/eslint-rules"
|
||||
]
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +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, dev mode (watch & sync), generate, uninstall, and function management
|
||||
- Strong TypeScript support and typed client generation
|
||||
|
||||
## Documentation
|
||||
See Twenty application documentation https://docs.twenty.com/developers/extend/capabilities/apps
|
||||
|
||||
## 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
|
||||
|
||||
# If you don't use yarn@4
|
||||
corepack enable
|
||||
yarn install
|
||||
|
||||
# Get help
|
||||
yarn run help
|
||||
|
||||
# Authenticate using your API key (you'll be prompted)
|
||||
yarn auth:login
|
||||
|
||||
# Add a new entity to your application (guided)
|
||||
yarn entity:add
|
||||
|
||||
# Generate a typed Twenty client and workspace entity types
|
||||
yarn app:generate
|
||||
|
||||
# Start dev mode: watches, builds, and syncs local changes to your workspace
|
||||
yarn app:dev
|
||||
|
||||
# Watch your application's function logs
|
||||
yarn function:logs
|
||||
|
||||
# Execute a function with a JSON payload
|
||||
yarn function:execute -n my-function -p '{"key": "value"}'
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn app:uninstall
|
||||
```
|
||||
|
||||
## What gets scaffolded
|
||||
- A minimal app structure ready for Twenty with example files:
|
||||
- `application-config.ts` - Application metadata configuration
|
||||
- `roles/default-role.ts` - Default role for logic functions
|
||||
- `logic-functions/hello-world.ts` - Example logic function with HTTP trigger
|
||||
- `front-components/hello-world.tsx` - Example front component
|
||||
- TypeScript configuration
|
||||
- Prewired scripts that wrap the `twenty` CLI from twenty-sdk
|
||||
|
||||
## Next steps
|
||||
- Use `yarn auth:login` to authenticate with your Twenty workspace.
|
||||
- Explore the generated project and add your first entity with `yarn entity:add` (logic functions, front components, objects, roles).
|
||||
- Use `yarn app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
|
||||
- Keep your types up‑to‑date using `yarn app:generate`.
|
||||
|
||||
|
||||
## 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:login` again and verify the API key permissions.
|
||||
- Types not generated: ensure `yarn app:generate` runs without errors, then re‑start `yarn app:dev`.
|
||||
|
||||
## Contributing
|
||||
- See our [GitHub](https://github.com/twentyhq/twenty)
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
@@ -1,20 +0,0 @@
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
ignores: ['**/dist/**'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
ignores: ['src/**/*.ts', '!src/cli/**/*.ts'],
|
||||
},
|
||||
];
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.4.8",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npx rimraf dist && npx vite build",
|
||||
"prepublishOnly": "tsx ../twenty-utils/pack-scripts/pre-publish-only.ts",
|
||||
"postpublish": "tsx ../twenty-utils/pack-scripts/post-publish.ts"
|
||||
},
|
||||
"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",
|
||||
"twenty-shared": "workspace:*",
|
||||
"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",
|
||||
"typescript": "^5.9.2",
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -1,46 +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:login
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
|
||||
```bash
|
||||
yarn app:dev
|
||||
```
|
||||
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Available Commands
|
||||
|
||||
```bash
|
||||
# Authentication
|
||||
yarn auth:login # Authenticate with Twenty
|
||||
yarn auth:logout # Remove credentials
|
||||
yarn auth:status # Check auth status
|
||||
yarn auth:switch # Switch default workspace
|
||||
yarn auth:list # List all configured workspaces
|
||||
|
||||
# Application
|
||||
yarn app:dev # Start dev mode (watch, build, and sync)
|
||||
yarn entity:add # Add a new entity (function, front-component, object, role)
|
||||
yarn app:generate # Generate typed Twenty client
|
||||
yarn function:logs # Stream function logs
|
||||
yarn function:execute # Execute a function with JSON payload
|
||||
yarn app:uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## 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,31 +0,0 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"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,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
import { install } from '@/utils/install';
|
||||
import { tryGitInit } from '@/utils/try-git-init';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import kebabCase from 'lodash.kebabcase';
|
||||
import * as path from 'path';
|
||||
|
||||
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 {
|
||||
const dirName = appDirectory.split('/').reverse()[0] ?? '';
|
||||
|
||||
console.log(chalk.green('✅ Application created!'));
|
||||
console.log('');
|
||||
console.log(chalk.blue('Next steps:'));
|
||||
console.log(chalk.gray(` cd ${dirName}`));
|
||||
console.log(chalk.gray(` corepack enable # if you don't use yarn@4`));
|
||||
console.log(chalk.gray(` yarn install # if you don't use yarn@4`));
|
||||
console.log(chalk.gray(' yarn auth:login # Authenticate with Twenty'));
|
||||
console.log(chalk.gray(' yarn app:dev # Start dev mode'));
|
||||
}
|
||||
}
|
||||
@@ -1,302 +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),
|
||||
};
|
||||
});
|
||||
|
||||
const APPLICATION_FILE_NAME = 'application-config.ts';
|
||||
const DEFAULT_ROLE_FILE_NAME = 'default-role.ts';
|
||||
|
||||
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/', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
// Verify src/ folder exists
|
||||
const srcAppPath = join(testAppDirectory, 'src');
|
||||
expect(await fs.pathExists(srcAppPath)).toBe(true);
|
||||
|
||||
// Verify application-config.ts exists in src/
|
||||
const appConfigPath = join(srcAppPath, APPLICATION_FILE_NAME);
|
||||
expect(await fs.pathExists(appConfigPath)).toBe(true);
|
||||
|
||||
// Verify default-role.ts exists in src/
|
||||
const roleConfigPath = join(srcAppPath, 'roles', DEFAULT_ROLE_FILE_NAME);
|
||||
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.4.8');
|
||||
expect(packageJson.scripts['app: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 defineApplication 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', APPLICATION_FILE_NAME);
|
||||
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
|
||||
|
||||
// Verify it uses defineApplication
|
||||
expect(appConfigContent).toContain(
|
||||
"import { defineApplication } from 'twenty-sdk'",
|
||||
);
|
||||
expect(appConfigContent).toContain('export default defineApplication({');
|
||||
|
||||
// Verify it imports the role identifier
|
||||
expect(appConfigContent).toContain(
|
||||
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-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(
|
||||
'defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create default-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',
|
||||
'roles',
|
||||
DEFAULT_ROLE_FILE_NAME,
|
||||
);
|
||||
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_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_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', APPLICATION_FILE_NAME);
|
||||
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', APPLICATION_FILE_NAME),
|
||||
'utf8',
|
||||
);
|
||||
const secondAppConfig = await fs.readFile(
|
||||
join(secondAppDir, 'src', APPLICATION_FILE_NAME),
|
||||
'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,
|
||||
});
|
||||
|
||||
const firstRoleConfig = await fs.readFile(
|
||||
join(firstAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const secondRoleConfig = await fs.readFile(
|
||||
join(secondAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Extract UUIDs using regex
|
||||
const uuidRegex =
|
||||
/DEFAULT_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,302 +0,0 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { v4 } from 'uuid';
|
||||
import { ASSETS_DIR } from 'twenty-shared/application';
|
||||
|
||||
const SRC_FOLDER = 'src';
|
||||
|
||||
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 createPublicAssetDirectory(appDirectory);
|
||||
|
||||
await createYarnLock(appDirectory);
|
||||
|
||||
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
|
||||
|
||||
await fs.ensureDir(sourceFolderPath);
|
||||
|
||||
await createDefaultRoleConfig({
|
||||
displayName: appDisplayName,
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'roles',
|
||||
fileName: 'default-role.ts',
|
||||
});
|
||||
|
||||
await createDefaultFrontComponent({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'front-components',
|
||||
fileName: 'hello-world.tsx',
|
||||
});
|
||||
|
||||
await createDefaultFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'hello-world.ts',
|
||||
});
|
||||
|
||||
await createApplicationConfig({
|
||||
displayName: appDisplayName,
|
||||
description: appDescription,
|
||||
appDirectory: sourceFolderPath,
|
||||
fileName: 'application-config.ts',
|
||||
});
|
||||
};
|
||||
|
||||
const createPublicAssetDirectory = async (appDirectory: string) => {
|
||||
await fs.ensureDir(join(appDirectory, ASSETS_DIR));
|
||||
};
|
||||
|
||||
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/
|
||||
|
||||
.twenty/*
|
||||
!.twenty/output/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
|
||||
};
|
||||
|
||||
const createDefaultRoleConfig = async ({
|
||||
displayName,
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
displayName: string;
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineRole } from 'twenty-sdk';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: '${displayName} default function role',
|
||||
description: '${displayName} default function role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createDefaultFrontComponent = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
export const HelloWorld = () => {
|
||||
return (
|
||||
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
||||
<h1>Hello, World!</h1>
|
||||
<p>This is your first front component.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'hello-world-front-component',
|
||||
description: 'A sample front component',
|
||||
component: HelloWorld,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createDefaultFunction = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
const triggerUniversalIdentifier = v4();
|
||||
|
||||
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
const handler = async (): Promise<{ message: string }> => {
|
||||
return { message: 'Hello, World!' };
|
||||
};
|
||||
|
||||
// Logic function handler - rename and implement your logic
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'hello-world-logic-function',
|
||||
description: 'A simple logic function',
|
||||
timeoutSeconds: 5,
|
||||
handler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: '${triggerUniversalIdentifier}',
|
||||
type: 'route',
|
||||
path: '/hello-world-logic-function',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createApplicationConfig = async ({
|
||||
displayName,
|
||||
description,
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const content = `import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '${v4()}',
|
||||
displayName: '${displayName}',
|
||||
description: '${description ?? ''}',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createPackageJson = async ({
|
||||
appName,
|
||||
appDirectory,
|
||||
}: {
|
||||
appName: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
const packageJson = {
|
||||
name: appName,
|
||||
version: '0.1.0',
|
||||
license: 'MIT',
|
||||
engines: {
|
||||
node: '^24.5.0',
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
packageManager: 'yarn@4.9.2',
|
||||
scripts: {
|
||||
'auth:login': 'twenty auth:login',
|
||||
'auth:logout': 'twenty auth:logout',
|
||||
'auth:status': 'twenty auth:status',
|
||||
'auth:switch': 'twenty auth:switch',
|
||||
'auth:list': 'twenty auth:list',
|
||||
'app:dev': 'twenty app:dev',
|
||||
'entity:add': 'twenty entity:add',
|
||||
'app:generate': 'twenty app:generate',
|
||||
'function:logs': 'twenty function:logs',
|
||||
'function:execute': 'twenty function:execute',
|
||||
'app:uninstall': 'twenty app:uninstall',
|
||||
help: 'twenty help',
|
||||
lint: 'eslint',
|
||||
'lint:fix': 'eslint --fix',
|
||||
},
|
||||
dependencies: {
|
||||
'twenty-sdk': '0.4.8',
|
||||
},
|
||||
devDependencies: {
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
'@types/react': '^18.2.0',
|
||||
react: '^18.2.0',
|
||||
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 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strictNullChecks": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"noEmit": true,
|
||||
"types": ["jest", "node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.d.ts",
|
||||
"**/__mocks__/**/*",
|
||||
"vite.config.ts",
|
||||
"jest.config.mjs"
|
||||
]
|
||||
}
|
||||
@@ -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,97 +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',
|
||||
'util',
|
||||
],
|
||||
output: [
|
||||
{
|
||||
format: 'es',
|
||||
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
|
||||
},
|
||||
{
|
||||
format: 'cjs',
|
||||
interop: 'auto',
|
||||
esModule: true,
|
||||
exports: 'named',
|
||||
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
logLevel: 'warn',
|
||||
};
|
||||
});
|
||||
@@ -1,2 +1 @@
|
||||
generated
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
Vendored
Executable
+942
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -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.
+942
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -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 a@x.com] [--participant b@x.com] [--channel <channelId>] [--mine] [--dry-run]`
|
||||
|
||||
- Filters (combine as needed):
|
||||
- `--from` / `--to`: ISO or date string range filter
|
||||
- `--organizer` / `--participant`: comma-separated emails
|
||||
- `--channel`: Fireflies channel id
|
||||
- `--mine`: only meetings for the current Fireflies user
|
||||
- Controls:
|
||||
- `--dry-run`: list and transform without writing to Twenty
|
||||
- `--page-size`: pagination size (default 50)
|
||||
- `--max-records`: stop after N transcripts (default 500)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -230,7 +173,7 @@ npm run test -- --watch
|
||||
npx twenty-cli app dev
|
||||
|
||||
# Type checking
|
||||
npx tsgo --noEmit
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Testing
|
||||
@@ -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": {
|
||||
@@ -24,7 +24,10 @@
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "tsc --noEmit --project {projectRoot}/tsconfig.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
@@ -33,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 alice@x.com] [--participant bob@x.com] [--channel <channelId>] [--mine] [--dry-run] [--page-size 50] [--max-records 200]
|
||||
*
|
||||
* Required env:
|
||||
* FIREFLIES_API_KEY
|
||||
* TWENTY_API_KEY
|
||||
*
|
||||
* Optional env:
|
||||
* SERVER_URL (defaults to http://localhost:3000)
|
||||
* FIREFLIES_PLAN (free|pro|business|enterprise)
|
||||
* AUTO_CREATE_CONTACTS (true|false)
|
||||
* FIREFLIES_* retry settings (see README)
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
import { existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { FirefliesApiClient } from '../src/fireflies-api-client';
|
||||
import { type HistoricalImportFilters, HistoricalImporter } from '../src/historical-importer';
|
||||
import { createLogger } from '../src/logger';
|
||||
import { TwentyCrmService } from '../src/twenty-crm-service';
|
||||
import {
|
||||
getApiUrl,
|
||||
getFirefliesPlan,
|
||||
getSummaryFetchConfig,
|
||||
shouldAutoCreateContacts,
|
||||
} from '../src/utils';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
|
||||
const logger = createLogger('cli:meeting:all');
|
||||
|
||||
type CliArgs = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
organizer?: string[];
|
||||
participant?: string[];
|
||||
channel?: string;
|
||||
host?: string;
|
||||
mine?: boolean;
|
||||
dryRun?: boolean;
|
||||
pageSize?: number;
|
||||
maxRecords?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const parseArgs = (argv: string[]): CliArgs => {
|
||||
const args: CliArgs = {};
|
||||
|
||||
const parseNumberArg = (value?: string): number | undefined => {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const current = argv[i];
|
||||
const next = argv[i + 1];
|
||||
switch (current) {
|
||||
case '--from':
|
||||
args.from = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--to':
|
||||
args.to = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--organizer':
|
||||
args.organizer = next ? next.split(',') : [];
|
||||
i += 1;
|
||||
break;
|
||||
case '--participant':
|
||||
args.participant = next ? next.split(',') : [];
|
||||
i += 1;
|
||||
break;
|
||||
case '--channel':
|
||||
args.channel = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--host':
|
||||
args.host = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--mine':
|
||||
args.mine = true;
|
||||
break;
|
||||
case '--dry-run':
|
||||
args.dryRun = true;
|
||||
break;
|
||||
case '--page-size':
|
||||
args.pageSize = parseNumberArg(next);
|
||||
i += 1;
|
||||
break;
|
||||
case '--max-records':
|
||||
args.maxRecords = parseNumberArg(next);
|
||||
i += 1;
|
||||
break;
|
||||
case '--limit':
|
||||
args.limit = parseNumberArg(next);
|
||||
i += 1;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
};
|
||||
|
||||
const parseDate = (value?: string): number | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
console.error('❌ FIREFLIES_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!twentyApiKey) {
|
||||
console.error('❌ TWENTY_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fromDate = parseDate(args.from);
|
||||
const toDate = parseDate(args.to);
|
||||
|
||||
const filters: HistoricalImportFilters = {
|
||||
fromDate,
|
||||
toDate,
|
||||
organizers: args.organizer,
|
||||
participants: args.participant,
|
||||
channelId: args.channel,
|
||||
hostEmail: args.host,
|
||||
mine: args.mine,
|
||||
limit: args.limit,
|
||||
pageSize: args.pageSize,
|
||||
maxRecords: args.maxRecords,
|
||||
};
|
||||
|
||||
const summaryConfig = getSummaryFetchConfig();
|
||||
const plan = getFirefliesPlan();
|
||||
const autoCreateContacts = shouldAutoCreateContacts();
|
||||
|
||||
logger.info(
|
||||
`Starting historical import (dryRun=${Boolean(args.dryRun)}, plan=${plan}, pageSize=${filters.pageSize ?? 50})`,
|
||||
);
|
||||
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
|
||||
const importer = new HistoricalImporter(firefliesClient, twentyService);
|
||||
|
||||
const result = await importer.run(filters, {
|
||||
dryRun: args.dryRun,
|
||||
autoCreateContacts,
|
||||
summaryConfig,
|
||||
plan,
|
||||
});
|
||||
|
||||
console.log('✅ Historical import summary:');
|
||||
const summary = {
|
||||
dryRun: result.dryRun,
|
||||
totalListed: result.totalListed,
|
||||
imported: result.imported,
|
||||
skippedExisting: result.skippedExisting,
|
||||
summaryPending: result.summaryPending,
|
||||
failed: result.failed,
|
||||
};
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
||||
if (result.statuses.length > 0) {
|
||||
console.log('Status by meeting:');
|
||||
console.table(
|
||||
result.statuses.map((s) => ({
|
||||
meetingId: s.meetingId,
|
||||
title: s.title ?? '',
|
||||
status: s.status,
|
||||
reason: s.reason ?? '',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (result.failed.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to import historical meetings');
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
if (error.stack) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
} else {
|
||||
console.error(String(error));
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user