Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84de47ea6 |
@@ -12,7 +12,6 @@ This directory contains Twenty's development guidelines and best practices in th
|
||||
### Core Guidelines
|
||||
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
|
||||
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
|
||||
- **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files)
|
||||
|
||||
### Code Quality
|
||||
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
|
||||
@@ -41,7 +40,7 @@ You can manually reference any rule using the `@ruleName` syntax:
|
||||
- `@testing-guidelines` - Get testing recommendations
|
||||
|
||||
### Rule Types Used
|
||||
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
|
||||
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
|
||||
- **Auto Attached** - Loaded when matching file patterns are referenced
|
||||
- **Agent Requested** - Available for AI to include when relevant
|
||||
- **Manual** - Only included when explicitly mentioned
|
||||
@@ -56,9 +55,7 @@ npx nx storybook:build twenty-front # Build Storybook
|
||||
npx nx storybook:serve-and-test:static # Run Storybook tests
|
||||
|
||||
# Development
|
||||
npx nx lint:diff-with-main twenty-front # Lint files changed vs main (fastest)
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
|
||||
npx nx lint twenty-front # Lint all files (slower)
|
||||
npx nx lint twenty-front # Run linter
|
||||
npx nx typecheck twenty-front # Type checking
|
||||
npx nx run twenty-front:graphql:generate # Generate GraphQL types
|
||||
```
|
||||
@@ -72,9 +69,7 @@ npx nx run twenty-server:database:migrate:prod # Run migrations
|
||||
|
||||
# Development
|
||||
npx nx run twenty-server:start # Start the server
|
||||
npx nx lint:diff-with-main twenty-server # Lint files changed vs main (fastest)
|
||||
npx nx lint:diff-with-main twenty-server --configuration=fix # Auto-fix changed files
|
||||
npx nx run twenty-server:lint # Lint all files (slower)
|
||||
npx nx run twenty-server:lint # Run linter (add --fix to auto-fix)
|
||||
npx nx run twenty-server:typecheck # Type checking
|
||||
npx nx run twenty-server:test # Run unit tests
|
||||
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
|
||||
|
||||
@@ -89,8 +89,6 @@ Use the AI codebase search to find:
|
||||
|
||||
### 1. Setup Git Branch
|
||||
|
||||
**IMPORTANT**: Always start from an up-to-date main branch to avoid merge conflicts and ensure the changelog is based on the latest code.
|
||||
|
||||
```bash
|
||||
cd /Users/thomascolasdesfrancs/code/twenty
|
||||
git checkout main
|
||||
@@ -100,8 +98,6 @@ git checkout -b {VERSION}
|
||||
|
||||
Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
|
||||
|
||||
⚠️ **Do this first** before making any file changes. This ensures your branch is based on the latest main.
|
||||
|
||||
### 2. Create File Structure
|
||||
|
||||
**Create changelog file:**
|
||||
@@ -178,7 +174,6 @@ Description of the third feature.
|
||||
- Focus on user benefits, not technical implementation
|
||||
- Use active voice
|
||||
- Start with what the user can now do
|
||||
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
|
||||
|
||||
**Reference Previous Changelogs:**
|
||||
- Check `packages/twenty-website/src/content/releases/` for examples
|
||||
|
||||
@@ -30,18 +30,6 @@ type ButtonProps = {}; // Component props suffix with 'Props'
|
||||
// ✅ Files and directories - kebab-case
|
||||
// user-profile.component.tsx
|
||||
// user-profile.styles.ts
|
||||
|
||||
// ❌ NEVER use abbreviations in variable names
|
||||
// Bad
|
||||
const users = data.map((u) => u.name);
|
||||
const field = items.find((f) => f.id === id);
|
||||
|
||||
// Good
|
||||
const users = data.map((user) => user.name);
|
||||
const field = items.find((item) => item.id === id);
|
||||
const fieldMetadata = inlineFields.find(
|
||||
(fieldMetadataItem) => fieldMetadataItem.name === fieldName,
|
||||
);
|
||||
```
|
||||
|
||||
## Import Organization
|
||||
@@ -71,11 +59,11 @@ const processUserData = (
|
||||
): ProcessedUser => {
|
||||
const processedUser = transformUserData(user);
|
||||
applyOptions(processedUser, options);
|
||||
|
||||
|
||||
if (callback) {
|
||||
callback(processedUser);
|
||||
}
|
||||
|
||||
|
||||
return processedUser;
|
||||
};
|
||||
```
|
||||
@@ -83,7 +71,7 @@ const processUserData = (
|
||||
## Comments
|
||||
```typescript
|
||||
// ✅ Use short-form comments, NOT JSDoc blocks
|
||||
// ✅ Explain business logic and non-obvious intentions (WHY, not WHAT)
|
||||
// ✅ Explain business logic and non-obvious intentions
|
||||
// Apply 15% discount for premium users with orders > $100
|
||||
const discount = isPremiumUser && orderTotal > 100 ? 0.15 : 0;
|
||||
|
||||
@@ -97,55 +85,12 @@ const calculateTotalPrice = (basePrice: number): number => {
|
||||
// Implementation
|
||||
};
|
||||
|
||||
// ❌ AVOID obvious comments that just describe what code does
|
||||
// Bad: Get all inline fields dynamically
|
||||
const { inlineFieldMetadataItems } = useFieldListFieldMetadataItems({...});
|
||||
|
||||
// Bad: Define standard fields in display order
|
||||
const standardFieldOrder = ['startsAt', 'endsAt', 'conferenceLink'];
|
||||
|
||||
// Bad: Split fields into standard and custom
|
||||
const standardFields = standardFieldOrder.map(...)
|
||||
|
||||
// ✅ GOOD: Only comment if explaining non-obvious business logic
|
||||
// Calendar events display standard fields first, then custom fields after participants
|
||||
// to maintain consistency with the legacy UI behavior
|
||||
const standardFields = standardFieldOrder.map(...)
|
||||
|
||||
// ❌ AVOID JSDoc blocks - use short comments instead
|
||||
/**
|
||||
* This style is NOT preferred in this codebase
|
||||
*/
|
||||
```
|
||||
|
||||
**Comment Guidelines:**
|
||||
- **DO** comment complex business rules or domain-specific logic
|
||||
- **DO** comment non-obvious algorithmic decisions
|
||||
- **DO** add TODOs for future improvements
|
||||
- **DON'T** comment obvious variable declarations or function calls
|
||||
- **DON'T** comment what is already clear from well-named variables/functions
|
||||
- **DON'T** add comments that just repeat what the code says
|
||||
|
||||
## Utility Helpers
|
||||
```typescript
|
||||
// ✅ Use existing utility helpers instead of manual checks
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isNonEmptyString, isNonEmptyArray } from '@sniptt/guards';
|
||||
|
||||
// ❌ Manual type guards
|
||||
const validItems = items.filter((item): item is Item => item !== undefined);
|
||||
const hasValue = value !== null && value !== undefined;
|
||||
|
||||
// ✅ Use utility helpers
|
||||
const validItems = items.filter(isDefined);
|
||||
const hasValue = isDefined(value);
|
||||
|
||||
// Other useful helpers:
|
||||
// - isDefined(value) - checks !== null && !== undefined
|
||||
// - isNonEmptyString(value) - checks string is defined and not empty
|
||||
// - isNonEmptyArray(value) - checks array is defined and has items
|
||||
```
|
||||
|
||||
## Security Patterns
|
||||
```typescript
|
||||
// ✅ CSV Export: Always apply security first, then formatting
|
||||
|
||||
@@ -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,22 +0,0 @@
|
||||
#
|
||||
# Crowdin CLI configuration for App translations (twenty-front, twenty-server, twenty-emails)
|
||||
# Project ID: 1
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"preserve_hierarchy": true
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Documentation translations
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"project_id": 2
|
||||
"preserve_hierarchy": true
|
||||
"base_url": "https://twenty.api.crowdin.com"
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# MDX documentation files - user-guide
|
||||
# Using md type to preserve JSX component structure
|
||||
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
|
||||
#
|
||||
"source": "packages/twenty-docs/user-guide/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - developers
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/developers/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - twenty-ui
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# Navigation labels template - translated into per-locale navigation.json
|
||||
#
|
||||
"source": "packages/twenty-docs/navigation/navigation.template.json",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
versioning-strategy: "lockfile-only"
|
||||
assignees:
|
||||
- "mabdullahabaid"
|
||||
ignore:
|
||||
- dependency-name: "@graphql-yoga/nestjs"
|
||||
- dependency-name: "@nestjs/graphql"
|
||||
- dependency-name: "@ptc-org/nestjs-query-graphql"
|
||||
- dependency-name: "typeorm"
|
||||
+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
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Build twenty-emails
|
||||
run: npx nx build twenty-emails
|
||||
- name: Run email tests
|
||||
@@ -40,17 +40,17 @@ jobs:
|
||||
# Start the email server in the background
|
||||
npx nx run twenty-emails:start &
|
||||
SERVER_PID=$!
|
||||
|
||||
|
||||
# Wait for server to start
|
||||
sleep 20
|
||||
|
||||
|
||||
# Check if server is running
|
||||
if ! curl -s http://localhost:4001/preview/test.email > /dev/null; then
|
||||
echo "Email server failed to start"
|
||||
kill $SERVER_PID
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Kill the server
|
||||
kill $SERVER_PID
|
||||
ci-emails-status-check:
|
||||
@@ -61,4 +61,4 @@ jobs:
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
run: exit 1
|
||||
+21
-174
@@ -1,9 +1,11 @@
|
||||
name: CI Front and E2E
|
||||
name: CI Front
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
merge_group:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,9 +15,8 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v3-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
@@ -27,13 +28,6 @@ jobs:
|
||||
packages/twenty-front/**
|
||||
packages/twenty-ui/**
|
||||
packages/twenty-shared/**
|
||||
changed-files-check-e2e:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/**
|
||||
playwright.config.ts
|
||||
.github/workflows/ci-front.yaml
|
||||
front-sb-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
@@ -51,17 +45,15 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Diagnostic disk space issue
|
||||
run: df -h
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Front / Clean storybook-static
|
||||
run: rm -rf packages/twenty-front/storybook-static
|
||||
- name: Front / Build storybook
|
||||
run: npx nx storybook:build twenty-front
|
||||
- name: Save storybook build cache
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
|
||||
front-sb-test:
|
||||
@@ -82,11 +74,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Install Playwright
|
||||
run: cd packages/twenty-front && npx playwright install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
- name: Front / Write .env
|
||||
@@ -115,7 +107,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
|
||||
@@ -129,7 +121,7 @@ jobs:
|
||||
run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
|
||||
front-chromatic-deployment:
|
||||
timeout-minutes: 30
|
||||
if: false
|
||||
if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push'
|
||||
needs: front-sb-build
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
@@ -140,11 +132,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
|
||||
- name: Front / Write .env
|
||||
run: |
|
||||
cd packages/twenty-front
|
||||
@@ -173,162 +165,26 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore ${{ matrix.task }} cache
|
||||
id: restore-task-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.TASK_CACHE_KEY }}
|
||||
- name: Reset .env
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: reset:env
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
- name: Save ${{ matrix.task }} cache
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-task-cache.outputs.cache-primary-key }}
|
||||
front-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/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
|
||||
@@ -337,7 +193,7 @@ jobs:
|
||||
[
|
||||
changed-files-check,
|
||||
front-task,
|
||||
front-build,
|
||||
front-chromatic-deployment,
|
||||
merge-reports-and-check-coverage,
|
||||
front-sb-test,
|
||||
front-sb-build,
|
||||
@@ -346,12 +202,3 @@ jobs:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
ci-e2e-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check-e2e, e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
@@ -56,4 +56,4 @@ jobs:
|
||||
title: Release v${{ steps.sanitize.outputs.version }}
|
||||
labels: |
|
||||
release
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
@@ -1,102 +0,0 @@
|
||||
name: CI SDK
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/twenty-sdk/**
|
||||
sdk-test:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/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: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:sdk
|
||||
tasks: ${{ matrix.task }}
|
||||
sdk-e2e-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: [changed-files-check, sdk-test]
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
NODE_ENV: test
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Server / Append billing config to .env.test
|
||||
working-directory: packages/twenty-server
|
||||
run: |
|
||||
echo "" >> .env.test
|
||||
echo "IS_BILLING_ENABLED=true" >> .env.test
|
||||
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
|
||||
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Server / Create Test DB
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
- name: SDK / Run E2E Tests
|
||||
run: npx nx test:e2e twenty-sdk
|
||||
ci-sdk-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, sdk-test, sdk-e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -1,9 +1,11 @@
|
||||
name: CI Server
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
merge_group:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -57,16 +59,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
id: restore-server-setup-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
- name: Server / Run lint & typecheck
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: lint,typecheck
|
||||
@@ -124,13 +126,13 @@ jobs:
|
||||
npx nx run twenty-front:graphql:generate
|
||||
npx nx run twenty-front:graphql:generate --configuration=metadata
|
||||
|
||||
# Check if GraphQL generated files were modified
|
||||
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata; then
|
||||
# Check if any files were modified
|
||||
if ! git diff --quiet; then
|
||||
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
|
||||
echo ""
|
||||
echo "The following GraphQL schema changes were detected:"
|
||||
echo "==================================================="
|
||||
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata
|
||||
git diff
|
||||
echo "==================================================="
|
||||
echo ""
|
||||
echo "Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
|
||||
@@ -138,7 +140,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
- name: Save server setup
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
|
||||
server-test:
|
||||
@@ -151,13 +153,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Run Tests
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: test
|
||||
@@ -169,7 +171,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
@@ -207,14 +209,14 @@ jobs:
|
||||
ANALYTICS_ENABLED: true
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
SHARD_COUNTER: 8
|
||||
SHARD_COUNTER: 5
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Update .env.test for integrations tests
|
||||
run: |
|
||||
echo "" >> .env.test
|
||||
@@ -224,7 +226,7 @@ jobs:
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Restore server setup
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Build
|
||||
@@ -241,7 +243,7 @@ jobs:
|
||||
- name: Run ClickHouse seeds
|
||||
run: npx nx clickhouse:seed twenty-server
|
||||
- name: Server / Run Integration Tests
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: 'test:integration'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
name: CI Shared
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -36,9 +38,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
name: CI Docker Compose
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
name: 'Test Docker Compose'
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -30,12 +30,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Utils / Run Danger.js
|
||||
run: cd packages/twenty-utils && npx nx danger:ci
|
||||
env:
|
||||
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
|
||||
|
||||
|
||||
congratulate:
|
||||
timeout-minutes: 3
|
||||
runs-on: ubuntu-latest
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run congratulate-dangerfile.js
|
||||
run: cd packages/twenty-utils && npx nx danger:congratulate
|
||||
env:
|
||||
|
||||
@@ -4,7 +4,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -45,7 +47,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Server / Create DB
|
||||
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
|
||||
@@ -21,11 +21,6 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/twenty-docs/**'
|
||||
- '.github/crowdin-docs.yml'
|
||||
- '.github/workflows/docs-i18n-pull.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@@ -40,16 +35,12 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Setup i18n-docs branch
|
||||
if: github.event_name != 'pull_request'
|
||||
- name: Setup i18n branch
|
||||
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: |
|
||||
@@ -57,71 +48,40 @@ jobs:
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
|
||||
- name: Stash any changes before pulling translations
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
git add .
|
||||
git stash || true
|
||||
|
||||
# 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 ==="
|
||||
if: inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
download_language: fr
|
||||
source: 'packages/twenty-docs/**/*.mdx'
|
||||
translation: 'packages/twenty-docs/l/%two_letters_code%/**/%original_file_name%'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
skip_untranslated_files: true
|
||||
push_translations: false
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Fix file permissions
|
||||
if: github.event_name != 'pull_request'
|
||||
run: sudo chown -R runner:docker . || true
|
||||
|
||||
- name: Fix translated documentation links
|
||||
run: bash packages/twenty-docs/scripts/fix-translated-links.sh
|
||||
|
||||
- name: Regenerate navigation template
|
||||
if: github.event_name == 'pull_request'
|
||||
run: yarn docs:generate-navigation-template
|
||||
|
||||
- name: Regenerate docs.json
|
||||
run: yarn docs:generate
|
||||
|
||||
- name: Commit artifacts to pull request branch
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json
|
||||
if git diff --staged --quiet --exit-code; then
|
||||
echo "No navigation/doc changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: sync docs artifacts"
|
||||
git push origin "HEAD:$HEAD_REF"
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
|
||||
- name: Check for changes and commit
|
||||
if: github.event_name != 'pull_request'
|
||||
id: check_changes
|
||||
run: |
|
||||
git add .
|
||||
@@ -133,14 +93,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Push changes
|
||||
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n-docs
|
||||
if: steps.check_changes.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n
|
||||
|
||||
- name: Create pull request
|
||||
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
|
||||
if: 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
|
||||
|
||||
@@ -10,9 +10,8 @@ on:
|
||||
branches: ['main']
|
||||
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 }}
|
||||
@@ -27,13 +26,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Generate navigation template for Crowdin
|
||||
run: yarn docs:generate-navigation-template
|
||||
ref: main
|
||||
|
||||
- name: Upload docs to Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
@@ -41,11 +34,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
|
||||
@@ -74,8 +74,6 @@ jobs:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: '**/en.po'
|
||||
translation: '%original_path%/%locale%.po'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
@@ -85,14 +83,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 +97,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 +106,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
|
||||
|
||||
@@ -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
+1
-1
@@ -67,7 +67,7 @@
|
||||
"--config",
|
||||
"./jest-integration.config.ts",
|
||||
"${relativeFile}",
|
||||
"--silent=false",
|
||||
"--silent=false"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/packages/twenty-server",
|
||||
"console": "integratedTerminal",
|
||||
|
||||
Vendored
-1
@@ -31,7 +31,6 @@
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"javascript.format.enable": false,
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.format.enable": false,
|
||||
"cSpell.enableFiletypes": [
|
||||
"!javascript",
|
||||
|
||||
@@ -36,15 +36,10 @@ When testing the UI end to end, click on "Continue with Email" and use the prefi
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Linting (diff with main - fastest)
|
||||
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
|
||||
npx nx lint:diff-with-main twenty-server # Lint only files changed vs main
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix files changed vs main
|
||||
|
||||
# Linting (full project)
|
||||
npx nx lint twenty-front # Lint all files in frontend
|
||||
npx nx lint twenty-server # Lint all files in backend
|
||||
npx nx lint twenty-front --fix # Auto-fix all linting issues
|
||||
# Linting
|
||||
npx nx lint twenty-front # Frontend linting
|
||||
npx nx lint twenty-server # Backend linting
|
||||
npx nx lint twenty-front --fix # Auto-fix linting issues
|
||||
|
||||
# Type checking
|
||||
npx nx typecheck twenty-front
|
||||
|
||||
@@ -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
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
#
|
||||
# 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%",
|
||||
}
|
||||
]
|
||||
+6
-2
@@ -75,6 +75,10 @@ export default [
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -137,10 +141,10 @@ export default [
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowInterfaces: 'with-single-extends',
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
const nxPreset = require('@nx/jest/preset').default;
|
||||
|
||||
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 $@
|
||||
@@ -4,9 +4,7 @@
|
||||
"libsDir": "packages"
|
||||
},
|
||||
"namedInputs": {
|
||||
"default": [
|
||||
"{projectRoot}/**/*"
|
||||
],
|
||||
"default": ["{projectRoot}/**/*"],
|
||||
"excludeStories": [
|
||||
"default",
|
||||
"!{projectRoot}/.storybook/*",
|
||||
@@ -34,26 +32,17 @@
|
||||
"targetDefaults": {
|
||||
"build": {
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"^production",
|
||||
"production"
|
||||
],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"inputs": ["^production", "production"],
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"start": {
|
||||
"cache": false,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"cache": true,
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"cache": true,
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
],
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"eslintConfig": "{projectRoot}/eslint.config.mjs",
|
||||
"cache": true,
|
||||
@@ -67,22 +56,7 @@
|
||||
"fix": true
|
||||
}
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"lint:diff-with-main": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": false,
|
||||
"options": {
|
||||
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs \"$@\"; fi' _",
|
||||
"pattern": "\\.(ts|tsx|js|jsx)$"
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs --fix \"$@\"; fi' _"
|
||||
}
|
||||
}
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"fmt": {
|
||||
"executor": "nx:run-commands",
|
||||
@@ -103,9 +77,7 @@
|
||||
"write": true
|
||||
}
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
@@ -119,30 +91,22 @@
|
||||
"watch": true
|
||||
}
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeStories",
|
||||
"{workspaceRoot}/jest.preset.js"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/coverage"
|
||||
],
|
||||
"outputs": ["{projectRoot}/coverage"],
|
||||
"options": {
|
||||
"jestConfig": "{projectRoot}/jest.config.mjs",
|
||||
"coverage": true,
|
||||
"coverageReporters": [
|
||||
"text-summary"
|
||||
],
|
||||
"coverageReporters": ["text-summary"],
|
||||
"cacheDirectory": "../../.cache/jest/{projectRoot}"
|
||||
},
|
||||
"configurations": {
|
||||
@@ -151,10 +115,7 @@
|
||||
"maxWorkers": 3
|
||||
},
|
||||
"coverage": {
|
||||
"coverageReporters": [
|
||||
"lcov",
|
||||
"text"
|
||||
]
|
||||
"coverageReporters": ["lcov", "text"]
|
||||
},
|
||||
"watch": {
|
||||
"watch": true
|
||||
@@ -163,36 +124,25 @@
|
||||
},
|
||||
"test:e2e": {
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"storybook:build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeTests"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/{options.output-dir}"
|
||||
],
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"outputs": ["{projectRoot}/{options.output-dir}"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "NODE_OPTIONS='--max-old-space-size=10240' VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
|
||||
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build --test",
|
||||
"output-dir": "storybook-static",
|
||||
"config-dir": ".storybook"
|
||||
},
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"storybook:serve:dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"dependsOn": ["^build"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "storybook dev",
|
||||
@@ -201,9 +151,7 @@
|
||||
},
|
||||
"storybook:serve:static": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": [
|
||||
"storybook:build"
|
||||
],
|
||||
"dependsOn": ["storybook:build"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx http-server {args.staticDir} -a={args.host} --port={args.port} --silent={args.silent}",
|
||||
@@ -216,13 +164,8 @@
|
||||
"storybook:test": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeTests"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/coverage/storybook"
|
||||
],
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"outputs": ["{projectRoot}/coverage/storybook"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"commands": [
|
||||
@@ -238,10 +181,7 @@
|
||||
},
|
||||
"storybook:test:no-coverage": {
|
||||
"executor": "nx:run-commands",
|
||||
"inputs": [
|
||||
"^default",
|
||||
"excludeTests"
|
||||
],
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"commands": [
|
||||
@@ -294,7 +234,7 @@
|
||||
"command": "nx storybook:build {projectName}",
|
||||
"forwardAllArgs": false
|
||||
},
|
||||
"chromatic --storybook-build-dir=storybook-static {args.ci}"
|
||||
"cross-var chromatic --project-token=$CHROMATIC_PROJECT_TOKEN --storybook-build-dir=storybook-static {args.ci}"
|
||||
],
|
||||
"parallel": false
|
||||
},
|
||||
@@ -331,24 +271,16 @@
|
||||
},
|
||||
"@nx/vite:test": {
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"default",
|
||||
"^default"
|
||||
]
|
||||
"inputs": ["default", "^default"]
|
||||
},
|
||||
"@nx/vite:build": {
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"inputs": [
|
||||
"default",
|
||||
"^default"
|
||||
]
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["default", "^default"]
|
||||
}
|
||||
},
|
||||
"installation": {
|
||||
"version": "22.0.3"
|
||||
"version": "21.3.11"
|
||||
},
|
||||
"generators": {
|
||||
"@nx/react": {
|
||||
@@ -376,9 +308,7 @@
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
"options": {
|
||||
"cacheableOperations": [
|
||||
"storybook:build"
|
||||
]
|
||||
"cacheableOperations": ["storybook:build"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+41
-32
@@ -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,35 +69,42 @@
|
||||
"@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.0.3",
|
||||
"@nx/eslint-plugin": "22.0.3",
|
||||
"@nx/jest": "22.0.3",
|
||||
"@nx/js": "22.0.3",
|
||||
"@nx/react": "22.0.3",
|
||||
"@nx/storybook": "22.0.3",
|
||||
"@nx/vite": "22.0.3",
|
||||
"@nx/web": "22.0.3",
|
||||
"@nx/eslint": "21.3.11",
|
||||
"@nx/eslint-plugin": "21.3.11",
|
||||
"@nx/jest": "21.3.11",
|
||||
"@nx/js": "21.3.11",
|
||||
"@nx/react": "21.3.11",
|
||||
"@nx/storybook": "21.3.11",
|
||||
"@nx/vite": "21.3.11",
|
||||
"@nx/web": "21.3.11",
|
||||
"@playwright/test": "^1.46.0",
|
||||
"@sentry/types": "^8",
|
||||
"@storybook-community/storybook-addon-cookie": "^5.0.0",
|
||||
"@storybook/addon-coverage": "^3.0.0",
|
||||
"@storybook/addon-docs": "^10.1.11",
|
||||
"@storybook/addon-links": "^10.1.11",
|
||||
"@storybook/icons": "^2.0.1",
|
||||
"@storybook/react-vite": "^10.1.11",
|
||||
"@storybook/test-runner": "^0.24.2",
|
||||
"@storybook/addon-actions": "8.6.14",
|
||||
"@storybook/addon-coverage": "^1.0.0",
|
||||
"@storybook/addon-essentials": "8.6.14",
|
||||
"@storybook/addon-interactions": "8.6.14",
|
||||
"@storybook/addon-links": "8.6.14",
|
||||
"@storybook/blocks": "8.6.14",
|
||||
"@storybook/core-server": "8.6.14",
|
||||
"@storybook/icons": "^1.2.9",
|
||||
"@storybook/preview-api": "8.6.14",
|
||||
"@storybook/react": "8.6.14",
|
||||
"@storybook/react-vite": "8.6.14",
|
||||
"@storybook/test": "8.6.14",
|
||||
"@storybook/test-runner": "^0.23.0",
|
||||
"@storybook/types": "8.6.14",
|
||||
"@stylistic/eslint-plugin": "^1.5.0",
|
||||
"@swc-node/register": "1.8.0",
|
||||
"@swc/cli": "^0.3.12",
|
||||
"@swc/core": "1.13.3",
|
||||
"@swc/helpers": "~0.5.2",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
@@ -102,6 +112,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",
|
||||
@@ -138,6 +149,7 @@
|
||||
"@yarnpkg/types": "^4.0.0",
|
||||
"chromatic": "^6.18.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-var": "^1.1.0",
|
||||
"danger": "^13.0.4",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"esbuild": "^0.25.10",
|
||||
@@ -154,7 +166,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",
|
||||
@@ -165,21 +177,26 @@
|
||||
"jsdom": "~22.1.0",
|
||||
"msw": "^2.0.11",
|
||||
"msw-storybook-addon": "^2.0.5",
|
||||
"nx": "22.0.3",
|
||||
"nx": "21.3.11",
|
||||
"playwright": "^1.46.0",
|
||||
"prettier": "^3.1.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.5",
|
||||
"source-map-support": "^0.5.20",
|
||||
"storybook": "^10.1.11",
|
||||
"storybook-addon-mock-date": "2.0.0",
|
||||
"storybook-addon-pseudo-states": "^10.1.11",
|
||||
"storybook": "8.6.14",
|
||||
"storybook-addon-cookie": "^3.2.0",
|
||||
"storybook-addon-pseudo-states": "^2.1.2",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.17.0",
|
||||
"vite": "^7.0.0"
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-checker": "^0.10.2",
|
||||
"vite-plugin-cjs-interop": "^2.2.0",
|
||||
"vite-plugin-dts": "3.8.1",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -200,8 +217,6 @@
|
||||
"version": "0.2.1",
|
||||
"nx": {},
|
||||
"scripts": {
|
||||
"docs:generate": "tsx packages/twenty-docs/scripts/generate-docs-json.ts",
|
||||
"docs:generate-navigation-template": "tsx packages/twenty-docs/scripts/generate-navigation-template.ts",
|
||||
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
|
||||
},
|
||||
"workspaces": {
|
||||
@@ -219,13 +234,7 @@
|
||||
"packages/twenty-sdk",
|
||||
"packages/twenty-apps",
|
||||
"packages/twenty-cli",
|
||||
"packages/create-twenty-app",
|
||||
"tools/eslint-rules"
|
||||
]
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<div align="center">
|
||||
<a href="https://twenty.com">
|
||||
<picture>
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg" height="128">
|
||||
</picture>
|
||||
</a>
|
||||
<h1>Create Twenty App</h1>
|
||||
|
||||
<a href="https://www.npmjs.com/package/create-twenty-app"><img alt="NPM version" src="https://img.shields.io/npm/v/create-twenty-app.svg?style=for-the-badge&labelColor=000000"></a>
|
||||
<a href="https://github.com/twentyhq/twenty/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/npm/l/next.svg?style=for-the-badge&labelColor=000000"></a>
|
||||
<a href="https://discord.gg/cx5n4Jzs57"><img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-blueviolet.svg?style=for-the-badge&logo=Twenty&labelColor=000000&logoWidth=20"></a>
|
||||
|
||||
</div>
|
||||
|
||||
Create Twenty App is the official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). It sets up a ready‑to‑run project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
|
||||
|
||||
- Zero‑config project bootstrap
|
||||
- Preconfigured scripts for auth, generate, dev sync, one‑off sync, uninstall
|
||||
- Strong TypeScript support and typed client generation
|
||||
|
||||
## Prerequisites
|
||||
- Node.js 24+ (recommended) and Yarn 4
|
||||
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# Authenticate using your API key (you'll be prompted)
|
||||
yarn auth
|
||||
|
||||
# Add a new entity to your application (guided)
|
||||
yarn create-entity
|
||||
|
||||
# Generate a typed Twenty client and workspace entity types
|
||||
yarn generate
|
||||
|
||||
# Start dev mode: automatically syncs local changes to your workspace
|
||||
yarn dev
|
||||
|
||||
# Or run a one‑time sync
|
||||
yarn sync
|
||||
|
||||
# Watch your application's functions logs
|
||||
yarn logs
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn uninstall
|
||||
|
||||
# Display commands' help
|
||||
yarn help
|
||||
```
|
||||
|
||||
## What gets scaffolded
|
||||
- A minimal app structure ready for Twenty
|
||||
- TypeScript configuration
|
||||
- Prewired scripts that wrap the `twenty` CLI from twenty-sdk
|
||||
- Example placeholders to help you add entities, actions, and sync logic
|
||||
|
||||
## Next steps
|
||||
- Explore the generated project and add your first entity with `yarn create-entity`.
|
||||
- Keep your types up‑to‑date using `yarn generate`.
|
||||
- Use `yarn dev` while you iterate to see changes instantly in your workspace.
|
||||
|
||||
|
||||
## Publish your application
|
||||
Applications are currently stored in `twenty/packages/twenty-apps`.
|
||||
|
||||
You can share your application with all Twenty users:
|
||||
|
||||
```bash
|
||||
# pull the Twenty project
|
||||
git clone https://github.com/twentyhq/twenty.git
|
||||
cd twenty
|
||||
|
||||
# create a new branch
|
||||
git checkout -b feature/my-awesome-app
|
||||
```
|
||||
|
||||
- Copy your app folder into `twenty/packages/twenty-apps`.
|
||||
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
|
||||
|
||||
```bash
|
||||
git commit -m "Add new application"
|
||||
git push
|
||||
```
|
||||
|
||||
Our team reviews contributions for quality, security, and reusability before merging.
|
||||
|
||||
## Troubleshooting
|
||||
- Auth prompts not appearing: run `yarn auth` again and verify the API key permissions.
|
||||
- Types not generated: ensure `yarn generate` runs without errors, then re‑start `yarn dev`.
|
||||
|
||||
## Contributing
|
||||
- See our [GitHub](https://github.com/twentyhq/twenty)
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.3.1",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npx rimraf dist && npx vite build"
|
||||
},
|
||||
"keywords": [
|
||||
"twenty",
|
||||
"cli",
|
||||
"crm",
|
||||
"application",
|
||||
"development"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/cli.d.ts",
|
||||
"import": "./dist/cli.mjs",
|
||||
"require": "./dist/cli.cjs"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@genql/cli": "^3.0.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"inquirer": "^10.0.0",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"lodash.startcase": "^4.4.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
"@types/inquirer": "^9.0.0",
|
||||
"@types/lodash.camelcase": "^4.3.7",
|
||||
"@types/lodash.kebabcase": "^4.1.7",
|
||||
"@types/lodash.startcase": "^4",
|
||||
"@types/node": "^20.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"yarn": "^4.0.2"
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:create-app"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["{projectRoot}/dist"]
|
||||
},
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"cwd": "packages/create-twenty-app",
|
||||
"command": "tsx src/cli.ts"
|
||||
}
|
||||
},
|
||||
"start": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"cwd": "packages/create-twenty-app",
|
||||
"command": "node dist/cli.cjs"
|
||||
}
|
||||
},
|
||||
"typecheck": {},
|
||||
"lint": {
|
||||
"options": {
|
||||
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
|
||||
"maxWarnings": 0
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
|
||||
"maxWarnings": 0
|
||||
},
|
||||
"fix": {}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "{projectRoot}/jest.config.mjs"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"coverage": true,
|
||||
"watchAll": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommanderError } from 'commander';
|
||||
import { CreateAppCommand } from '@/create-app.command';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const program = new Command(packageJson.name)
|
||||
.description('CLI tool to initialize a new Twenty application')
|
||||
.version(
|
||||
packageJson.version,
|
||||
'-v, --version',
|
||||
'Output the current version of create-twenty-app.',
|
||||
)
|
||||
.argument('[directory]')
|
||||
.helpOption('-h, --help', 'Display this help message.')
|
||||
.action(async (directory?: string) => {
|
||||
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
await new CreateAppCommand().execute(directory);
|
||||
});
|
||||
|
||||
program.exitOverride();
|
||||
|
||||
try {
|
||||
program.parse();
|
||||
} catch (error) {
|
||||
if (error instanceof CommanderError) {
|
||||
process.exit(error.exitCode);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
console.error(chalk.red('Error:'), error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
24.5.0
|
||||
Binary file not shown.
@@ -1,27 +0,0 @@
|
||||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn auth
|
||||
```
|
||||
|
||||
Then, install this app to your workspace:
|
||||
|
||||
```bash
|
||||
yarn sync
|
||||
```
|
||||
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Twenty applications, take a look at the following resources:
|
||||
|
||||
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
|
||||
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
|
||||
@@ -1,29 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
// Base JS recommended rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript recommended rules
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Common TypeScript-friendly tweaks
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-unused-vars': 'off', // handled by TS rule
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import * as path from 'path';
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
import kebabCase from 'lodash.kebabcase';
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
import { tryGitInit } from '@/utils/try-git-init';
|
||||
import { install } from '@/utils/install';
|
||||
|
||||
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
|
||||
|
||||
export class CreateAppCommand {
|
||||
async execute(directory?: string): Promise<void> {
|
||||
try {
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
await this.getAppInfos(directory);
|
||||
|
||||
await this.validateDirectory(appDirectory);
|
||||
|
||||
this.logCreationInfo({ appDirectory, appName });
|
||||
|
||||
await fs.ensureDir(appDirectory);
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
|
||||
await install(appDirectory);
|
||||
|
||||
await tryGitInit(appDirectory);
|
||||
|
||||
this.logSuccess(appDirectory);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('Initialization failed:'),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppInfos(directory?: string): Promise<{
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}> {
|
||||
const { name, displayName, description } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Application name:',
|
||||
when: () => !directory,
|
||||
default: 'my-awesome-app',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) return 'Application name is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'displayName',
|
||||
message: 'Application display name:',
|
||||
default: (answers: any) => {
|
||||
return convertToLabel(answers?.name ?? directory);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'description',
|
||||
message: 'Application description (optional):',
|
||||
default: '',
|
||||
},
|
||||
]);
|
||||
|
||||
const computedName = name ?? directory;
|
||||
|
||||
const appName = computedName.trim();
|
||||
|
||||
const appDisplayName = displayName.trim();
|
||||
|
||||
const appDescription = description.trim();
|
||||
|
||||
const appDirectory = directory
|
||||
? path.join(CURRENT_EXECUTION_DIRECTORY, directory)
|
||||
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
|
||||
|
||||
return { appName, appDisplayName, appDirectory, appDescription };
|
||||
}
|
||||
|
||||
private async validateDirectory(appDirectory: string): Promise<void> {
|
||||
if (!(await fs.pathExists(appDirectory))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.readdir(appDirectory);
|
||||
if (files.length > 0) {
|
||||
throw new Error(
|
||||
`Directory ${appDirectory} already exists and is not empty`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private logCreationInfo({
|
||||
appDirectory,
|
||||
appName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
appName: string;
|
||||
}): void {
|
||||
console.log(chalk.blue('🎯 Creating Twenty Application'));
|
||||
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
|
||||
console.log(chalk.gray(`📝 Name: ${appName}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
private logSuccess(appDirectory: string): void {
|
||||
console.log(chalk.green('✅ Application created!'));
|
||||
console.log('');
|
||||
console.log(chalk.blue('Next steps:'));
|
||||
console.log(`cd ${appDirectory.split('/').reverse()[0] ?? ''}`);
|
||||
console.log('yarn auth');
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
|
||||
// Mock fs-extra's copy function to skip copying base template (not available during tests)
|
||||
jest.mock('fs-extra', () => {
|
||||
const actual = jest.requireActual('fs-extra');
|
||||
return {
|
||||
...actual,
|
||||
copy: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
describe('copyBaseApplicationProject', () => {
|
||||
let testAppDirectory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique temp directory for each test
|
||||
testAppDirectory = join(
|
||||
tmpdir(),
|
||||
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
await fs.ensureDir(testAppDirectory);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temp directory after each test
|
||||
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
|
||||
await fs.remove(testAppDirectory);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create the correct folder structure with src/app/', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
// Verify src/app/ folder exists
|
||||
const srcAppPath = join(testAppDirectory, 'src', 'app');
|
||||
expect(await fs.pathExists(srcAppPath)).toBe(true);
|
||||
|
||||
// Verify application.config.ts exists in src/app/
|
||||
const appConfigPath = join(srcAppPath, 'application.config.ts');
|
||||
expect(await fs.pathExists(appConfigPath)).toBe(true);
|
||||
|
||||
// Verify default-function.role.ts exists in src/app/
|
||||
const roleConfigPath = join(srcAppPath, 'default-function.role.ts');
|
||||
expect(await fs.pathExists(roleConfigPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should create package.json with correct content', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const packageJsonPath = join(testAppDirectory, 'package.json');
|
||||
expect(await fs.pathExists(packageJsonPath)).toBe(true);
|
||||
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
expect(packageJson.name).toBe('my-test-app');
|
||||
expect(packageJson.version).toBe('0.1.0');
|
||||
expect(packageJson.dependencies['twenty-sdk']).toBe('0.3.1');
|
||||
expect(packageJson.scripts.sync).toBe('twenty app sync');
|
||||
expect(packageJson.scripts.dev).toBe('twenty app dev');
|
||||
});
|
||||
|
||||
it('should create .gitignore file', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const gitignorePath = join(testAppDirectory, '.gitignore');
|
||||
expect(await fs.pathExists(gitignorePath)).toBe(true);
|
||||
|
||||
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
||||
expect(gitignoreContent).toContain('/node_modules');
|
||||
expect(gitignoreContent).toContain('generated');
|
||||
});
|
||||
|
||||
it('should create yarn.lock file', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const yarnLockPath = join(testAppDirectory, 'yarn.lock');
|
||||
expect(await fs.pathExists(yarnLockPath)).toBe(true);
|
||||
|
||||
const yarnLockContent = await fs.readFile(yarnLockPath, 'utf8');
|
||||
expect(yarnLockContent).toContain('yarn lockfile v1');
|
||||
});
|
||||
|
||||
it('should create application.config.ts with defineApp and correct values', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const appConfigPath = join(
|
||||
testAppDirectory,
|
||||
'src',
|
||||
'app',
|
||||
'application.config.ts',
|
||||
);
|
||||
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
|
||||
|
||||
// Verify it uses defineApp
|
||||
expect(appConfigContent).toContain(
|
||||
"import { defineApp } from 'twenty-sdk'",
|
||||
);
|
||||
expect(appConfigContent).toContain('export default defineApp({');
|
||||
|
||||
// Verify it imports the role identifier
|
||||
expect(appConfigContent).toContain(
|
||||
"import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role'",
|
||||
);
|
||||
|
||||
// Verify display name and description
|
||||
expect(appConfigContent).toContain("displayName: 'My Test App'");
|
||||
expect(appConfigContent).toContain("description: 'A test application'");
|
||||
|
||||
// Verify it has a universalIdentifier (UUID format)
|
||||
expect(appConfigContent).toMatch(
|
||||
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
|
||||
);
|
||||
|
||||
// Verify it references the role
|
||||
expect(appConfigContent).toContain(
|
||||
'functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create default-function.role.ts with defineRole and correct values', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const roleConfigPath = join(
|
||||
testAppDirectory,
|
||||
'src',
|
||||
'app',
|
||||
'default-function.role.ts',
|
||||
);
|
||||
const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8');
|
||||
|
||||
// Verify it uses defineRole
|
||||
expect(roleConfigContent).toContain(
|
||||
"import { defineRole } from 'twenty-sdk'",
|
||||
);
|
||||
expect(roleConfigContent).toContain('export default defineRole({');
|
||||
|
||||
// Verify it exports the universal identifier constant
|
||||
expect(roleConfigContent).toContain(
|
||||
'export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER',
|
||||
);
|
||||
|
||||
// Verify role label includes app name
|
||||
expect(roleConfigContent).toContain(
|
||||
"label: 'My Test App default function role'",
|
||||
);
|
||||
|
||||
// Verify default permissions
|
||||
expect(roleConfigContent).toContain('canReadAllObjectRecords: true');
|
||||
expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true');
|
||||
expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true');
|
||||
expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false');
|
||||
|
||||
// Verify it has a universalIdentifier (UUID format)
|
||||
expect(roleConfigContent).toMatch(
|
||||
/universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call fs.copy to copy base application template', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
// Verify fs.copy was called with correct destination
|
||||
expect(fs.copy).toHaveBeenCalledTimes(1);
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('base-application'),
|
||||
testAppDirectory,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty description', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: '',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const appConfigPath = join(
|
||||
testAppDirectory,
|
||||
'src',
|
||||
'app',
|
||||
'application.config.ts',
|
||||
);
|
||||
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
|
||||
|
||||
expect(appConfigContent).toContain("description: ''");
|
||||
});
|
||||
|
||||
it('should generate unique UUIDs for each application', async () => {
|
||||
// Create first app
|
||||
const firstAppDir = join(testAppDirectory, 'app1');
|
||||
await fs.ensureDir(firstAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-one',
|
||||
appDisplayName: 'App One',
|
||||
appDescription: 'First app',
|
||||
appDirectory: firstAppDir,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-two',
|
||||
appDisplayName: 'App Two',
|
||||
appDescription: 'Second app',
|
||||
appDirectory: secondAppDir,
|
||||
});
|
||||
|
||||
// Read both app configs
|
||||
const firstAppConfig = await fs.readFile(
|
||||
join(firstAppDir, 'src', 'app', 'application.config.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const secondAppConfig = await fs.readFile(
|
||||
join(secondAppDir, 'src', 'app', 'application.config.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Extract UUIDs using regex
|
||||
const uuidRegex =
|
||||
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
|
||||
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
|
||||
const secondUuid = secondAppConfig.match(uuidRegex)?.[1];
|
||||
|
||||
expect(firstUuid).toBeDefined();
|
||||
expect(secondUuid).toBeDefined();
|
||||
expect(firstUuid).not.toBe(secondUuid);
|
||||
});
|
||||
|
||||
it('should generate unique role UUIDs for each application', async () => {
|
||||
// Create first app
|
||||
const firstAppDir = join(testAppDirectory, 'app1');
|
||||
await fs.ensureDir(firstAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-one',
|
||||
appDisplayName: 'App One',
|
||||
appDescription: 'First app',
|
||||
appDirectory: firstAppDir,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-two',
|
||||
appDisplayName: 'App Two',
|
||||
appDescription: 'Second app',
|
||||
appDirectory: secondAppDir,
|
||||
});
|
||||
|
||||
// Read both role configs
|
||||
const firstRoleConfig = await fs.readFile(
|
||||
join(firstAppDir, 'src', 'app', 'default-function.role.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const secondRoleConfig = await fs.readFile(
|
||||
join(secondAppDir, 'src', 'app', 'default-function.role.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Extract UUIDs using regex
|
||||
const uuidRegex =
|
||||
/DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
|
||||
const firstUuid = firstRoleConfig.match(uuidRegex)?.[1];
|
||||
const secondUuid = secondRoleConfig.match(uuidRegex)?.[1];
|
||||
|
||||
expect(firstUuid).toBeDefined();
|
||||
expect(secondUuid).toBeDefined();
|
||||
expect(firstUuid).not.toBe(secondUuid);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
|
||||
describe('convertToLabel', () => {
|
||||
it('should convert to label', () => {
|
||||
expect(convertToLabel('toto')).toBe('Toto');
|
||||
expect(convertToLabel('totoTata')).toBe('Toto tata');
|
||||
expect(convertToLabel('totoTataTiti')).toBe('Toto tata titi');
|
||||
expect(convertToLabel('toto-tata-titi')).toBe('Toto tata titi');
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
const APP_FOLDER = 'src/app';
|
||||
|
||||
export const copyBaseApplicationProject = async ({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
}: {
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
|
||||
|
||||
await createPackageJson({ appName, appDirectory });
|
||||
|
||||
await createGitignore(appDirectory);
|
||||
|
||||
await createYarnLock(appDirectory);
|
||||
|
||||
const appFolderPath = join(appDirectory, APP_FOLDER);
|
||||
|
||||
await fs.ensureDir(appFolderPath);
|
||||
|
||||
await createDefaultServerlessFunctionRoleConfig({
|
||||
displayName: appDisplayName,
|
||||
appDirectory: appFolderPath,
|
||||
});
|
||||
|
||||
await createApplicationConfig({
|
||||
displayName: appDisplayName,
|
||||
description: appDescription,
|
||||
appDirectory: appFolderPath,
|
||||
});
|
||||
};
|
||||
|
||||
const createYarnLock = async (appDirectory: string) => {
|
||||
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
|
||||
};
|
||||
const createGitignore = async (appDirectory: string) => {
|
||||
const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
|
||||
};
|
||||
|
||||
const createDefaultServerlessFunctionRoleConfig = async ({
|
||||
displayName,
|
||||
appDirectory,
|
||||
}: {
|
||||
displayName: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineRole } from 'twenty-sdk';
|
||||
|
||||
export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: '${displayName} default function role',
|
||||
description: '${displayName} default function role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'default-function.role.ts'), content);
|
||||
};
|
||||
|
||||
const createApplicationConfig = async ({
|
||||
displayName,
|
||||
description,
|
||||
appDirectory,
|
||||
}: {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
const content = `import { defineApp } from 'twenty-sdk';
|
||||
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role';
|
||||
|
||||
export default defineApp({
|
||||
universalIdentifier: '${v4()}',
|
||||
displayName: '${displayName}',
|
||||
description: '${description ?? ''}',
|
||||
functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'application.config.ts'), content);
|
||||
};
|
||||
|
||||
const createPackageJson = async ({
|
||||
appName,
|
||||
appDirectory,
|
||||
}: {
|
||||
appName: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
const packageJson = {
|
||||
name: appName,
|
||||
version: '0.1.0',
|
||||
license: 'MIT',
|
||||
engines: {
|
||||
node: '^24.5.0',
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
packageManager: 'yarn@4.9.2',
|
||||
scripts: {
|
||||
'create-entity': 'twenty app add',
|
||||
dev: 'twenty app dev',
|
||||
generate: 'twenty app generate',
|
||||
sync: 'twenty app sync',
|
||||
logs: 'twenty app logs',
|
||||
uninstall: 'twenty app uninstall',
|
||||
help: 'twenty help',
|
||||
auth: 'twenty auth login',
|
||||
lint: 'eslint',
|
||||
'lint-fix': 'eslint --fix',
|
||||
},
|
||||
dependencies: {
|
||||
'twenty-sdk': '0.3.1',
|
||||
},
|
||||
devDependencies: {
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
eslint: '^9.32.0',
|
||||
'typescript-eslint': '^8.50.0',
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
join(appDirectory, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { startCase } from 'lodash';
|
||||
|
||||
export const convertToLabel = (str: string) => {
|
||||
const s = startCase(str).toLowerCase();
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
export const install = async (root: string) => {
|
||||
try {
|
||||
await execPromise('yarn', { cwd: root });
|
||||
} catch (error: any) {
|
||||
console.error(chalk.red('yarn install failed:'), error.stdout);
|
||||
}
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
const isInGitRepository = async (root: string): Promise<boolean> => {
|
||||
try {
|
||||
await execPromise('git rev-parse --is-inside-work-tree', { cwd: root });
|
||||
return true;
|
||||
} catch {
|
||||
// Empty catch block
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isInMercurialRepository = async (root: string): Promise<boolean> => {
|
||||
try {
|
||||
await execPromise('hg --cwd . root', { cwd: root });
|
||||
return true;
|
||||
} catch {
|
||||
// Empty catch block
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isDefaultBranchSet = async (root: string): Promise<boolean> => {
|
||||
try {
|
||||
await execPromise('git config init.defaultBranch', { cwd: root });
|
||||
return true;
|
||||
} catch {
|
||||
// Empty catch block
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const tryGitInit = async (root: string): Promise<boolean> => {
|
||||
try {
|
||||
await execPromise('git --version', { cwd: root });
|
||||
|
||||
if (
|
||||
(await isInGitRepository(root)) ||
|
||||
(await isInMercurialRepository(root))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
await execPromise('git init', { cwd: root });
|
||||
|
||||
try {
|
||||
if (!(await isDefaultBranchSet(root))) {
|
||||
await execPromise('git checkout -b main', { cwd: root });
|
||||
}
|
||||
|
||||
await execPromise('git add -A', { cwd: root });
|
||||
await execPromise(
|
||||
'git commit -m "Initial commit from Create Twenty App"',
|
||||
{
|
||||
cwd: root,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
fs.rm(join(root, '.git'), { recursive: true, force: true });
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strictNullChecks": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/__tests__/**"
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"**/__mocks__/**/*",
|
||||
"vite.config.ts",
|
||||
"jest.config.mjs",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx"
|
||||
]
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import packageJson from './package.json';
|
||||
|
||||
const moduleEntries = Object.keys((packageJson as any).exports || {})
|
||||
.filter(
|
||||
(key) => key !== './style.css' && key !== '.' && !key.startsWith('./src/'),
|
||||
)
|
||||
.map((module) => `src/${module.replace(/^\.\//, '')}/index.ts`);
|
||||
|
||||
const entries = ['src/cli.ts', ...moduleEntries];
|
||||
|
||||
const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
|
||||
if (!chunk.isEntry) {
|
||||
throw new Error(
|
||||
`Should never occurs, encountered a non entry chunk ${chunk.facadeModuleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
|
||||
if (splitFaceModuleId === undefined) {
|
||||
throw new Error(
|
||||
`Should never occurs splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
|
||||
if (moduleDirectory === 'src') {
|
||||
return `${chunk.name}.${extension}`;
|
||||
}
|
||||
return `${moduleDirectory}.${extension}`;
|
||||
};
|
||||
|
||||
const copyAssetPlugin = (targets: { src: string; dest: string }[]) => {
|
||||
return {
|
||||
name: 'copy-assets',
|
||||
closeBundle: async () => {
|
||||
for (const target of targets) {
|
||||
await fs.copy(
|
||||
path.resolve(__dirname, target.src),
|
||||
path.resolve(__dirname, target.dest),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default defineConfig(() => {
|
||||
const tsConfigPath = path.resolve(__dirname, './tsconfig.lib.json');
|
||||
|
||||
return {
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/create-twenty-app',
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: __dirname,
|
||||
}),
|
||||
dts({ entryRoot: './src', tsconfigPath: tsConfigPath }),
|
||||
copyAssetPlugin([
|
||||
{
|
||||
src: 'src/constants/base-application',
|
||||
dest: 'dist/constants/base-application',
|
||||
},
|
||||
]),
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
lib: { entry: entries, name: 'create-twenty-app' },
|
||||
rollupOptions: {
|
||||
external: [
|
||||
...Object.keys((packageJson as any).dependencies || {}),
|
||||
'path',
|
||||
'fs',
|
||||
'child_process',
|
||||
],
|
||||
output: [
|
||||
{
|
||||
format: 'es',
|
||||
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
|
||||
},
|
||||
{
|
||||
format: 'cjs',
|
||||
interop: 'auto',
|
||||
esModule: true,
|
||||
exports: 'named',
|
||||
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
logLevel: 'warn',
|
||||
};
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
generated
|
||||
@@ -1,108 +0,0 @@
|
||||
# Twenty CRM Activity Reporter ��
|
||||
|
||||
A TypeScript-based reporting bot that summarizes activity from your Twenty CRM workspace and sends daily/periodic reports to Slack, Discord, and WhatsApp. Meet Kylian Mbaguette, your friendly CRM activity reporter!
|
||||
|
||||
## Features
|
||||
|
||||
- 🧑💻 **People & Company Tracking**: Summarizes newly created people and companies
|
||||
- 🎯 **Opportunity Monitoring**: Reports on new opportunities created, broken down by stage
|
||||
- ✅ **Task Analytics**:
|
||||
- Tracks task creation
|
||||
- Calculates on-time completion rates
|
||||
- Identifies team members with the most overdue tasks (the "slackers")
|
||||
- 🔔 **Multi-Platform Notifications**: Send reports to Slack, Discord, and/or WhatsApp
|
||||
- ⏰ **Configurable Time Range**: Look back any number of days
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (v14 or higher recommended)
|
||||
- TypeScript
|
||||
- A [Twenty CRM](https://twenty.com) account with API access
|
||||
- Optional: Slack webhook, Discord webhook, and/or WhatsApp Business API access
|
||||
|
||||
## Installing dependencies
|
||||
```bash
|
||||
# Install dependencies
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `TWENTY_API_KEY` | ✅ Yes | Your Twenty CRM API key |
|
||||
| `DAYS_AGO` | ✅ Yes | Number of days to look back for the report |
|
||||
| `SLACK_HOOK_URL` | ❌ No | Slack incoming webhook URL |
|
||||
| `DISCORD_WEBHOOK_URL` | ❌ No | Discord webhook URL |
|
||||
| `FB_GRAPH_TOKEN` | ❌ No | Facebook Graph API token for WhatsApp |
|
||||
| `WHATSAPP_RECIPIENT_PHONE_NUMBER` | ❌ No | WhatsApp recipient phone number (with country code) |
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
.
|
||||
├── index.ts # Main entry point
|
||||
├── people-creation-summariser.ts # Summarizes people/company creation
|
||||
├── opportunity-creation-summariser.ts # Summarizes opportunity creation
|
||||
├── task-creation-summariser.ts # Summarizes task creation & completion
|
||||
├── senders.ts # Handles sending to Slack/Discord/WhatsApp
|
||||
├── utils.ts # API request utility
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Data Collection**: The bot queries the Twenty CRM API for activities within the specified time range
|
||||
2. **Analysis**:
|
||||
- Counts new people and companies
|
||||
- Categorizes opportunities by stage
|
||||
- Calculates task completion rates and identifies overdue tasks
|
||||
3. **Reporting**: Formats the data into friendly messages
|
||||
4. **Distribution**: Sends reports to configured platforms (Slack, Discord, WhatsApp)
|
||||
|
||||
## Report Format
|
||||
|
||||
Each report includes:
|
||||
```
|
||||
Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last X days:
|
||||
|
||||
🧑💻 People & Companies
|
||||
- X People and Y Companies were added
|
||||
|
||||
🎯 Opportunities
|
||||
- X Opportunities were added: Y in NEW, Z in PROPOSAL
|
||||
|
||||
📋 Tasks
|
||||
- X Tasks were created
|
||||
- Y% Tasks were completed on time
|
||||
- [Name] slacked the most with Z Tasks overdue
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
This bot uses the [Twenty CRM REST API](https://api.twenty.com/rest/). The following endpoints are used:
|
||||
|
||||
- `GET /people` - Fetch people data
|
||||
- `GET /opportunities` - Fetch opportunity data
|
||||
- `GET /tasks` - Fetch task data
|
||||
- `GET /workspaceMembers/{id}` - Fetch workspace member details
|
||||
|
||||
## Notes
|
||||
|
||||
- The "slacker" detection is lighthearted and identifies team members with the most overdue tasks
|
||||
- At least one messaging platform must be configured for the bot to send reports
|
||||
- The bot uses ISO date format (YYYY-MM-DD) for date filtering
|
||||
- Task completion percentage only considers incomplete tasks (excludes already completed tasks from the calculation)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Issue**: No messages being sent
|
||||
- **Solution**: Ensure at least one messaging platform is configured with valid credentials
|
||||
|
||||
**Issue**: API authentication errors
|
||||
- **Solution**: Verify your `TWENTY_API_KEY` is correct and has necessary permissions
|
||||
|
||||
**Issue**: WhatsApp messages not sending
|
||||
- **Solution**: Ensure both `FB_GRAPH_TOKEN` and `WHATSAPP_RECIPIENT_PHONE_NUMBER` are set correctly
|
||||
|
||||
## Contributing
|
||||
Built with ❤️ and 🥖 by Azmat, Ali and Mike from 9dots
|
||||
@@ -1 +0,0 @@
|
||||
.yarn
|
||||
@@ -1,167 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [0.3.1] - 2025-12-08
|
||||
|
||||
Import all
|
||||
|
||||
### Added
|
||||
- Historical import CLI: `yarn meeting:all` to fetch and insert historical Fireflies meetings with filters (date range, organizers, participants, channel, mine) and dry-run support.
|
||||
- Fireflies transcripts listing with pagination and date filtering to support bulk imports.
|
||||
|
||||
### Changed
|
||||
- Deduplication now checks `firefliesMeetingId` before creating meetings (webhook + bulk).
|
||||
- Shared historical importer pipeline reusing existing note/meeting formatting.
|
||||
|
||||
## [0.3.0] - 2025-12-08
|
||||
|
||||
Subscription-based query / Full transcript and AI notes for Pro+ / More
|
||||
|
||||
### Added
|
||||
- **Full transcript capture**: Meeting object now stores the complete meeting transcript with speaker names and timestamps (`transcript` field)
|
||||
- **Rich AI meeting notes**: Captures detailed AI-generated meeting notes from Fireflies (`notes` field with 7,000+ char summaries)
|
||||
- **Expanded summary fields**: Now fetches all available Fireflies summary data:
|
||||
- `notes` - Detailed AI-generated meeting notes with timestamps and section headers
|
||||
- `bullet_gist` - Emoji-enhanced bullet point summaries
|
||||
- `outline` / `shorthand_bullet` - Timestamped meeting outline
|
||||
- `gist` - One-sentence meeting summary
|
||||
- `short_summary` - Single paragraph summary
|
||||
- `short_overview` - Brief overview
|
||||
- **New Meeting fields**:
|
||||
- `transcript` - Full meeting transcript with speaker attribution
|
||||
- `notes` - AI-generated detailed notes
|
||||
- `audioUrl` - Link to audio recording (Pro+)
|
||||
- `videoUrl` - Link to video recording (Business+)
|
||||
- `meetingLink` - Original meeting link
|
||||
- `neutralPercent` - Neutral sentiment percentage
|
||||
- **Meeting delete utility**: New `yarn meeting:delete <meetingId>` script for cleanup and re-import
|
||||
- **Debug meeting utility**: New `scripts/debug-meeting.ts` to inspect raw Fireflies API responses
|
||||
|
||||
### Changed
|
||||
- **Plan-based GraphQL queries**: Completely redesigned query system with three tiers:
|
||||
- **Free**: Basic fields only (title, date, duration, participants, transcript_url, meeting_link)
|
||||
- **Pro**: Adds full transcript (`sentences`), summary fields, speakers, audio_url
|
||||
- **Business+**: Adds analytics, video_url, speaker stats, meeting metrics
|
||||
- **Action items parsing**: Fixed parsing of `action_items` which Fireflies returns as newline-separated string, not array
|
||||
- **Note body format**: Enhanced with Meeting Notes, Outline, Key Points sections from rich Fireflies data
|
||||
- **Import status**: Added `PARTIAL` status for imports missing summary/analytics data
|
||||
|
||||
### Fixed
|
||||
- Missing `notes` and `bullet_gist` fields in data transform (were fetched but not passed through)
|
||||
- Proper fallback: Uses `shorthand_bullet` when `outline` is empty (Fireflies stores outline content there)
|
||||
- Summary readiness detection now checks `notes` field in addition to `overview` and `action_items`
|
||||
|
||||
### Documentation
|
||||
- Updated README with complete API access comparison table by subscription plan
|
||||
- Documented all available Fireflies summary fields and their plan requirements
|
||||
|
||||
## [0.2.3] - 2025-12-06
|
||||
|
||||
### Added
|
||||
- **Meeting ingest utility**: New `yarn meeting:ingest <meetingId>` script to manually fetch and import specific Fireflies meetings into Twenty
|
||||
- **Plan-based field selection**: Added `FIREFLIES_PLAN` configuration to control which GraphQL fields are requested based on subscription level (free, pro, business, enterprise)
|
||||
- **Main entry point**: New `src/index.ts` centralizing all exports for cleaner imports
|
||||
|
||||
### Changed
|
||||
- **Auth configuration**: Disabled authentication requirement for webhook route (`isAuthRequired: false`) to support serverless deployments
|
||||
- **Signature verification fallback**: Webhook handler now supports signature in payload body as fallback when HTTP headers aren't forwarded to serverless functions (production doesn't work for Fireflies webhook)
|
||||
- **Improved type safety**: Replaced `any` types with proper TypeScript types throughout codebase
|
||||
|
||||
### Enhanced
|
||||
- **Webhook debugging**: Added detailed debug output including param keys, header info, and signature comparison details
|
||||
- **Test webhook script**: Includes signature in both header and payload, with diagnostic output for header forwarding status
|
||||
- **Documentation**: Added README sections on current twenty headers forward limitations and utility scripts
|
||||
|
||||
## [0.2.2] - 2025-11-04
|
||||
|
||||
### Added
|
||||
- **Enhanced logging system**: Introduced configurable `AppLogger` class with log level support (debug, info, warn, error, silent)
|
||||
- Environment-based log level configuration via `LOG_LEVEL` environment variable
|
||||
- Test environment detection to prevent log noise during testing
|
||||
- Context-aware logging with proper prefixes for better debugging
|
||||
- **Improved error handling**: Enhanced webhook signature verification with detailed debug logging
|
||||
- **Better debugging capabilities**: Added comprehensive logging throughout webhook processing pipeline
|
||||
|
||||
### Enhanced
|
||||
- **Webhook signature verification**: Improved signature validation with detailed logging for troubleshooting
|
||||
- **Error messages**: More descriptive error logging for failed operations and security violations
|
||||
- **Development experience**: Better debugging information for webhook processing and API interactions
|
||||
|
||||
|
||||
## [0.2.1] - 2025-11-03
|
||||
|
||||
### Added
|
||||
- **Import status tracking**: Added four new meeting fields to track import status and failure handling:
|
||||
- `importStatus` (SELECT) - Tracks SUCCESS, FAILED, PENDING, RETRYING states
|
||||
- `importError` (TEXT) - Stores error messages when imports fail
|
||||
- `lastImportAttempt` (DATE_TIME) - Timestamp of the last import attempt
|
||||
- `importAttempts` (NUMBER) - Counter for number of import attempts
|
||||
- **Automatic failure tracking**: Enhanced webhook handler to automatically create failed meeting records when processing fails
|
||||
- **Failed meeting formatter**: Added `toFailedMeetingCreateInput()` method to create standardized failed meeting records
|
||||
|
||||
### Enhanced
|
||||
- **Meeting type definition**: Extended `MeetingCreateInput` type with import tracking fields
|
||||
- **Success status tracking**: Successful meeting imports now automatically set `importStatus: 'SUCCESS'` and track timestamps
|
||||
- **Error handling**: Webhook processing failures are now captured and stored as meeting records for visibility and potential retry
|
||||
|
||||
## [0.2.0] - 2025-11-03
|
||||
|
||||
### Changed
|
||||
- **Major refactoring**: Split monolithic `receive-fireflies-notes.ts` into modular architecture:
|
||||
- `fireflies-api-client.ts` - Fireflies GraphQL API integration with retry logic
|
||||
- `twenty-crm-service.ts` - Twenty CRM operations (contacts, notes, meetings)
|
||||
- `formatters.ts` - Meeting and note body formatting
|
||||
- `webhook-handler.ts` - Main webhook orchestration
|
||||
- `webhook-validator.ts` - HMAC signature verification
|
||||
- `utils.ts` - Shared utility functions
|
||||
- `types.ts` - Centralized type definitions
|
||||
- **Schema update**: Changed Meeting `notes` field from `RICH_TEXT` to `RELATION` type linking to Note object
|
||||
- Enhanced participant extraction from multiple Fireflies API data sources (participants, meeting_attendees, speakers, meeting_attendance)
|
||||
- Improved organizer email matching with name-based heuristics
|
||||
- Updated note creation to use `bodyV2.markdown` format instead of legacy `body` field
|
||||
- Modernized Meeting object schema with proper link field types for transcriptUrl and recordingUrl
|
||||
- Enhanced test suite with improved mocking for new modular structure
|
||||
- **Configuration optimization**: Reduced default retry attempts from 30 to 5 with increased delay (120s) to better respect Fireflies API rate limits (50 requests/day for free/pro plans)
|
||||
- Updated field setup script to support relation field creation with Note object
|
||||
- Restructured exports: types now exported from `types.ts`, runtime functions from `index.ts`
|
||||
- Updated import paths in action handlers to use centralized index exports
|
||||
- Added TypeScript path mappings for `twenty-sdk` in workspace configuration
|
||||
|
||||
### Added
|
||||
- `createNoteTarget` method for linking notes to multiple participants
|
||||
- Support for extracting participants from extended Fireflies API response formats
|
||||
- Better organizer identification logic matching email usernames to speaker names
|
||||
- `axios` dependency for improved HTTP client capabilities
|
||||
- API subscription plan documentation highlighting rate limit differences (50/day vs 60/minute)
|
||||
- Enhanced README with rate limiting guidance and configuration documentation
|
||||
- Relation field creation support in field provisioning script
|
||||
|
||||
### Fixed
|
||||
- Note linking now properly associates a single note with multiple participants in 1:1 meetings
|
||||
- Participant extraction handles missing email addresses gracefully
|
||||
- Improved handling of various Fireflies participant data structures
|
||||
- Test mocks updated to use string format for participants (`"Name <email>"`) matching Fireflies API response format
|
||||
- Test assertions updated to validate `bodyV2.markdown` instead of deprecated `body` field
|
||||
|
||||
## [0.1.0] - 2025-11-02
|
||||
|
||||
### Added
|
||||
- HMAC SHA-256 signature verification for incoming Fireflies webhooks
|
||||
- Fireflies GraphQL client with retry logic, timeout handling, and summary readiness detection
|
||||
- Summary-focused meeting processing that extracts action items, sentiment, keywords, and transcript/recording links
|
||||
- Scripted custom field provisioning via `yarn setup:fields`
|
||||
- Local webhook testing workflow via `yarn test:webhook`
|
||||
- Comprehensive Jest suite (15 tests) covering authentication, API integration, summary strategies, and error handling
|
||||
|
||||
### Changed
|
||||
- Replaced legacy JSON manifests with TypeScript configuration:
|
||||
- `application.config.ts` now declares app metadata and configuration variables
|
||||
- `src/objects/meeting.ts` defines the Meeting object via `@ObjectMetadata`
|
||||
- `src/actions/receive-fireflies-notes.ts` exports the Fireflies webhook action plus its runtime config
|
||||
- Updated documentation (README, Deployment Guide, Testing) to reflect the new project layout and workflows
|
||||
- Switched utility scripts to `tsx` and aligned package management with the hello-world example
|
||||
|
||||
### Fixed
|
||||
- Resolved real-world Fireflies payload mismatch by adopting the minimal webhook schema
|
||||
- Replaced body-based secrets with header-driven HMAC verification
|
||||
- Ensured graceful degradation when summaries are pending or Fireflies is temporarily unavailable
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
# Fireflies
|
||||
|
||||
Automatically captures meeting notes with AI-generated summaries and insights from Fireflies.ai into your Twenty CRM.
|
||||
|
||||
### Current Status
|
||||
- Doesn't work with Fireflies webhook yet due to missing headers forwarding in twenty serverless func
|
||||
- Meeting ingestion utility scripts are available for individual meeting insertion and historical meetings with filters with yarn meeting:all
|
||||
|
||||
## Integration Overview
|
||||
|
||||
**Fireflies webhook → Fireflies API → Twenty CRM with summary-focused insights**
|
||||
|
||||
- **Summary-first approach** - Prioritizes action items, keywords, and sentiment over raw transcripts
|
||||
- **HMAC signature verification** - Secure webhook authentication
|
||||
- **Two-phase architecture** - Webhook notification → API data fetch → CRM record creation
|
||||
- **Contact identification** - Matches participants to existing contacts or creates new ones
|
||||
- **One-on-one meetings** (2 people) → Individual notes linked to each contact
|
||||
- **Multi-party meetings** (3+ people) → Meeting records with all attendees
|
||||
- **Business intelligence extraction** - Action items, sentiment scores, topics, meeting types
|
||||
- **Smart retry logic** - Handles async summary generation with exponential backoff
|
||||
- **Links transcripts and recordings** - Easy access to full Fireflies content
|
||||
- **Duplicate prevention** - Checks for existing meetings by title
|
||||
|
||||
## API Access by Subscription Plan
|
||||
|
||||
Fireflies API access varies by subscription tier. This integration automatically adapts queries based on your plan and falls back gracefully if restrictions are encountered.
|
||||
|
||||
### Plan Comparison
|
||||
|
||||
| Feature | Free | Pro | Business | Enterprise |
|
||||
|---------|:----:|:---:|:--------:|:----------:|
|
||||
| **API Rate Limit** | 50/day | 50/day | 60/min | 60/min |
|
||||
| **Basic Data** (title, date, duration) | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Participants List** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Transcript URL** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Speakers** | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Summary** (overview, keywords) | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Audio URL** | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Action Items** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Topics Discussed** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Video URL** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Sentiment Analytics** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Meeting Attendees (detailed)** | ❌ | ❌ | ✅ | ✅ |
|
||||
|
||||
### What You'll Get Per Plan
|
||||
|
||||
**Free Plan:**
|
||||
- Meeting title, date, duration
|
||||
- Participant names/emails (basic)
|
||||
- Link to transcript
|
||||
|
||||
**Pro Plan:**
|
||||
- Everything in Free, plus:
|
||||
- Speaker identification
|
||||
- AI summary (overview + keywords)
|
||||
- Audio recording URL
|
||||
|
||||
**Business Plan:**
|
||||
- Everything in Pro, plus:
|
||||
- Action items extraction
|
||||
- Topics discussed
|
||||
- Sentiment analysis (positive/negative/neutral %)
|
||||
- Video recording URL
|
||||
- Detailed meeting attendee info
|
||||
|
||||
### Configuration
|
||||
|
||||
Set your plan in `.env`:
|
||||
```bash
|
||||
FIREFLIES_PLAN=free # Options: free, pro, business, enterprise
|
||||
```
|
||||
|
||||
**Rate Limiting:** Free/Pro plans are limited to 50 API calls/day. The integration uses conservative retry settings by default to stay within limits.
|
||||
|
||||
## What Gets Captured
|
||||
|
||||
### Summary & Insights
|
||||
- **Action Items** - Concrete next steps and commitments
|
||||
- **Keywords** - Key topics and themes discussed
|
||||
- **Overview** - Executive summary of the meeting
|
||||
- **Topics Discussed** - Main discussion points
|
||||
- **Meeting Type** - Context (sales call, standup, demo, etc.)
|
||||
|
||||
### Analytics
|
||||
- **Sentiment Analysis** - Positive/negative/neutral percentages for deal health
|
||||
- **Engagement Metrics** - Participation levels (future)
|
||||
|
||||
### Resources
|
||||
- **Transcript Link** - Quick access to full Fireflies transcript
|
||||
- **Recording Link** - Video/audio recording when available
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Step 1: Authenticate with Twenty
|
||||
npx twenty-cli auth login
|
||||
|
||||
# Step 2: Sync the app to create Meeting object
|
||||
npx twenty-cli app sync packages/twenty-apps/fireflies
|
||||
|
||||
# Step 3: Install dependencies
|
||||
yarn install
|
||||
|
||||
# Step 4: Add custom fields
|
||||
yarn setup:fields
|
||||
```
|
||||
|
||||
(TODO: change when fields setup internal support)
|
||||
|
||||
### Configuration
|
||||
|
||||
⚠️ **Important**: The integration uses **conservative retry settings** to respect Fireflies' 50 requests/day API limit with free/pro plans. You may increase for more reactivity with higher plans.
|
||||
|
||||
**Required Environment Variables:**
|
||||
```bash
|
||||
FIREFLIES_API_KEY=your_api_key # From Fireflies settings
|
||||
TWENTY_API_KEY=your_api_key # From Twenty CRM settings
|
||||
SERVER_URL=https://your-domain.twenty.com
|
||||
```
|
||||
|
||||
**Optional (Recommended):**
|
||||
```bash
|
||||
FIREFLIES_WEBHOOK_SECRET=your_secret # For webhook security
|
||||
```
|
||||
|
||||
📖 **For detailed configuration, troubleshooting, and rate limit management**, see [WEBHOOK_CONFIGURATION.md](./WEBHOOK_CONFIGURATION.md)
|
||||
|
||||
### What Gets Created
|
||||
|
||||
#### Basic Installation (Step 2)
|
||||
The `app sync` command creates:
|
||||
- ✅ Meeting object with basic `name` field
|
||||
- ✅ Webhook endpoint at `/s/webhook/fireflies`
|
||||
|
||||
#### After Custom Fields Setup (Step 4)
|
||||
The `setup:fields` script adds 13 custom fields to store rich Fireflies data:
|
||||
|
||||
| Field Name | Type | Label | Description |
|
||||
|------------|------|-------|-------------|
|
||||
| `notes` | RICH_TEXT | Meeting Notes | AI-generated summary with overview, topics, action items, and insights |
|
||||
| `meetingDate` | DATE_TIME | Meeting Date | Date and time when the meeting occurred |
|
||||
| `duration` | NUMBER | Duration (minutes) | Meeting duration in minutes |
|
||||
| `meetingType` | TEXT | Meeting Type | Type of meeting (e.g., Sales Call, Sprint Planning, 1:1) |
|
||||
| `keywords` | TEXT | Keywords | Key topics and themes discussed (comma-separated) |
|
||||
| `sentimentScore` | NUMBER | Sentiment Score | Overall meeting sentiment (0-1 scale, 1 = most positive) |
|
||||
| `positivePercent` | NUMBER | Positive % | Percentage of positive sentiment in conversation |
|
||||
| `negativePercent` | NUMBER | Negative % | Percentage of negative sentiment in conversation |
|
||||
| `actionItemsCount` | NUMBER | Action Items | Number of action items identified |
|
||||
| `transcriptUrl` | LINKS | Transcript URL | Link to full transcript in Fireflies |
|
||||
| `recordingUrl` | LINKS | Recording URL | Link to video/audio recording in Fireflies |
|
||||
| `firefliesMeetingId` | TEXT | Fireflies Meeting ID | Unique identifier from Fireflies |
|
||||
| `organizerEmail` | TEXT | Organizer Email | Email address of the meeting organizer |
|
||||
|
||||
**Note:** Without custom fields, meetings will be created with just the title. The rich summary data will only be stored in Notes for 1-on-1 meetings.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
Check [.env.example](./.env.example)
|
||||
|
||||
### Summary Processing Strategies
|
||||
|
||||
| Strategy | Description | Use Case |
|
||||
|----------|-------------|----------|
|
||||
| `immediate_only` | Single fetch attempt, no retries | Fast processing, accept missing summaries if not ready |
|
||||
| `immediate_with_retry` | Attempts immediate fetch, retries with backoff | **Recommended** - Balances speed and reliability |
|
||||
| `delayed_polling` | Schedules background polling | For heavily loaded systems |
|
||||
| `basic_only` | Creates records without waiting for summaries | For basic transcript archival only |
|
||||
|
||||
## Webhook Setup
|
||||
|
||||
### Step 1: Get Your Webhook URL
|
||||
|
||||
Your webhook endpoint will be:
|
||||
```
|
||||
https://your-twenty-instance.com/s/webhook/fireflies
|
||||
```
|
||||
|
||||
### Step 2: Configure Fireflies Webhook
|
||||
|
||||
1. Log into Fireflies.ai
|
||||
2. https://app.fireflies.ai/settings#DeveloperSettings
|
||||
4. Enter your webhook URL
|
||||
5. Set **Secret**: Generate from there and set value of `FIREFLIES_WEBHOOK_SECRET`
|
||||
6. Save configuration
|
||||
|
||||
### Step 3: Verify Webhook
|
||||
|
||||
The integration uses **HMAC SHA-256 signature verification**:
|
||||
- Fireflies sends `x-hub-signature` header
|
||||
- Twenty verifies signature using your webhook secret
|
||||
- Invalid signatures are rejected immediately
|
||||
|
||||
### Current Platform Limitation (Headers)
|
||||
|
||||
- Twenty serverless route triggers currently do **not forward HTTP headers** to functions. Fireflies signatures sent in headers are stripped, so header-based verification does not work in production.
|
||||
- Workaround: the provided test script also includes the signature inside the payload; the handler falls back to that payload signature. Use this only for testing until header forwarding is supported.
|
||||
|
||||
## Utilities for meeting insertion (workarounds)
|
||||
|
||||
- Ingest a specific Fireflies meeting into Twenty:
|
||||
`yarn meeting:ingest <meetingId>` or `MEETING_ID=... yarn meeting:ingest`
|
||||
|
||||
- Fetch all/historical Fireflies meetings into Twenty:
|
||||
`yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer a@x.com] [--participant b@x.com] [--channel <channelId>] [--mine] [--dry-run]`
|
||||
|
||||
- Filters (combine as needed):
|
||||
- `--from` / `--to`: ISO or date string range filter
|
||||
- `--organizer` / `--participant`: comma-separated emails
|
||||
- `--channel`: Fireflies channel id
|
||||
- `--mine`: only meetings for the current Fireflies user
|
||||
- Controls:
|
||||
- `--dry-run`: list and transform without writing to Twenty
|
||||
- `--page-size`: pagination size (default 50)
|
||||
- `--max-records`: stop after N transcripts (default 500)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test -- --watch
|
||||
|
||||
# Development mode with live sync
|
||||
npx twenty-cli app dev
|
||||
|
||||
# Type checking
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The integration includes comprehensive test coverage:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run specific test suite
|
||||
npm test -- fireflies-webhook.spec.ts
|
||||
|
||||
# Run with coverage
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- HMAC signature verification
|
||||
- Fireflies GraphQL API integration
|
||||
- Summary processing with retry logic
|
||||
- Summary-focused CRM record creation
|
||||
- One-on-one vs multi-party meeting detection
|
||||
- Contact matching and creation
|
||||
- Duplicate prevention
|
||||
- Error handling and resilience
|
||||
|
||||
## CRM Record Structure
|
||||
|
||||
### One-on-One Meeting Note Example
|
||||
|
||||
```markdown
|
||||
# Meeting: Product Demo with Client (Sales Call)
|
||||
|
||||
**Date:** Monday, November 2, 2024, 02:00 PM
|
||||
**Duration:** 30 minutes
|
||||
**Participants:** Sarah Sales, John Client
|
||||
|
||||
## Overview
|
||||
Successful product demonstration with positive client feedback.
|
||||
Client expressed strong interest in the enterprise plan.
|
||||
|
||||
## Key Topics
|
||||
- product features
|
||||
- pricing discussion
|
||||
- integration capabilities
|
||||
- support options
|
||||
|
||||
## Action Items
|
||||
- Follow up with pricing proposal by Friday
|
||||
- Schedule technical deep-dive next week
|
||||
- Share case studies from similar clients
|
||||
|
||||
## Insights
|
||||
**Keywords:** product demo, pricing, technical requirements, integration
|
||||
**Sentiment:** 75% positive, 10% negative, 15% neutral
|
||||
**Meeting Type:** Sales Call
|
||||
|
||||
## Resources
|
||||
[View Full Transcript](https://app.fireflies.ai/transcript/xxx)
|
||||
[Watch Recording](https://app.fireflies.ai/recording/xxx)
|
||||
```
|
||||
|
||||
### Multi-Party Meeting Record
|
||||
|
||||
- Meeting object with title, date, and all attendees
|
||||
- Summary stored as meeting notes (structure same as above)
|
||||
- Action items potentially converted to separate tasks (future)
|
||||
- Keywords as tags/categories (future)
|
||||
|
||||
## Future Implementation Opportunities
|
||||
|
||||
Next iterations would enhance the **intelligence layer** to:
|
||||
|
||||
### AI-Powered Insights
|
||||
- **Extract pain points, objections & buying signals** automatically from transcripts
|
||||
- **Calculate deal health scores** based on conversation sentiment trends
|
||||
- **Auto-create contextualized tasks** with AI-suggested next steps and priorities
|
||||
- **Proactively flag at-risk deals** when negative signals appear
|
||||
- **Track conversation patterns** that correlate with deal success
|
||||
|
||||
### Enhanced Analytics
|
||||
- **Action item completion tracking** across deals
|
||||
- **Sentiment trend analysis** over time for account health
|
||||
- **Speaking time analysis** for meeting engagement insights
|
||||
- **Topic clustering** for product/feature interest patterns
|
||||
|
||||
### Workflow Automation
|
||||
- **Auto-assign follow-up tasks** based on action items
|
||||
- **Smart notifications** for urgent follow-ups
|
||||
- **Deal stage progression** based on meeting outcomes
|
||||
- **Competitive intelligence** extraction from conversations
|
||||
|
||||
**Integration**: Fireflies webhook → AI processing layer → Enhanced Twenty records
|
||||
|
||||
*This would require the current MVP to be stabilized and discussions about intelligence layer architecture and data privacy considerations.*
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
|
||||
displayName: 'Fireflies',
|
||||
description: 'Sync Fireflies meeting summaries, sentiment, and action items into Twenty.',
|
||||
icon: 'IconMicrophone',
|
||||
applicationVariables: {
|
||||
FIREFLIES_WEBHOOK_SECRET: {
|
||||
universalIdentifier: 'f51f7646-be9f-4ba9-9b75-160dd288cd0c',
|
||||
description: 'Secret key for verifying Fireflies webhook signatures',
|
||||
//isSecret: true,
|
||||
value: '',
|
||||
},
|
||||
FIREFLIES_API_KEY: {
|
||||
universalIdentifier: 'faa41f07-b28e-4500-b1c0-ce4b3d27924c',
|
||||
description: 'Fireflies GraphQL API key used to fetch meeting summaries',
|
||||
//isSecret: true,
|
||||
value: '',
|
||||
},
|
||||
FIREFLIES_PLAN: {
|
||||
universalIdentifier: '57dbb73c-aac5-4247-9fcc-a070bb669f16',
|
||||
description: 'Fireflies plan: free, pro, business, enterprise',
|
||||
value: 'free',
|
||||
},
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: '02756551-5bf7-4fb2-8e08-1f622008d305',
|
||||
description: 'Twenty API key used when running scripts locally',
|
||||
//isSecret: true,
|
||||
value: '',
|
||||
},
|
||||
SERVER_URL: {
|
||||
universalIdentifier: '9b3a5e8e-5973-4e6b-a059-2966075652aa',
|
||||
description: 'Base URL for the Twenty workspace (default: http://localhost:3000)',
|
||||
value: 'http://localhost:3000',
|
||||
},
|
||||
AUTO_CREATE_CONTACTS: {
|
||||
universalIdentifier: 'c4fa946e-e06b-4d54-afb6-288b0ac75bdf',
|
||||
description: 'Whether to auto-create contacts for unknown participants',
|
||||
value: 'true',
|
||||
},
|
||||
LOG_LEVEL: {
|
||||
universalIdentifier: '2b019cf1-d198-48dd-943e-110571aa541e',
|
||||
description: 'Log level: silent, error, warn, info, debug (default: error)',
|
||||
value: 'error',
|
||||
},
|
||||
CAPTURE_LOGS: {
|
||||
universalIdentifier: 'adbcc267-309d-49b2-af71-76f1299d863e',
|
||||
description: 'Capture logs in webhook response for debugging (true/false)',
|
||||
value: 'true',
|
||||
},
|
||||
FIREFLIES_SUMMARY_STRATEGY: {
|
||||
universalIdentifier: '562b43d9-cd47-4ec1-ae16-5cc7ebc9729b',
|
||||
description: 'Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only',
|
||||
value: 'immediate_with_retry',
|
||||
},
|
||||
FIREFLIES_RETRY_ATTEMPTS: {
|
||||
universalIdentifier: '670ca203-01ce-4ae8-8294-eb38b29434f2',
|
||||
description: 'Number of retry attempts when fetching summaries',
|
||||
value: '3',
|
||||
},
|
||||
FIREFLIES_RETRY_DELAY: {
|
||||
universalIdentifier: '2e8ccb82-9390-47ba-b628-ca2726931bce',
|
||||
description: 'Delay in milliseconds between retry attempts',
|
||||
value: '5000',
|
||||
},
|
||||
FIREFLIES_POLL_INTERVAL: {
|
||||
universalIdentifier: '904538f7-7bec-4ee6-9bac-5d43c619b667',
|
||||
description: 'Polling interval (ms) when using delayed polling strategy',
|
||||
value: '30000',
|
||||
},
|
||||
FIREFLIES_MAX_POLLS: {
|
||||
universalIdentifier: '84d54c97-5572-4c01-9039-764ab3aa87b8',
|
||||
description: 'Maximum number of polling attempts when waiting for summaries',
|
||||
value: '10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "fireflies",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"setup:fields": "tsx scripts/add-meeting-fields.ts",
|
||||
"test:webhook": "tsx scripts/test-webhook.ts",
|
||||
"meeting:ingest": "tsx scripts/ingest-meeting.ts",
|
||||
"meeting:delete": "tsx scripts/delete-meeting.ts",
|
||||
"meeting:all": "tsx scripts/fetch-all-meetings.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"twenty-sdk": "0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/node": "^24.9.2",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "fireflies",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/twenty-apps/community/fireflies/src",
|
||||
"projectType": "application",
|
||||
"tags": [
|
||||
"scope:apps"
|
||||
],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": [
|
||||
"{workspaceRoot}/coverage/{projectRoot}"
|
||||
],
|
||||
"options": {
|
||||
"jestConfig": "packages/twenty-apps/community/fireflies/jest.config.mjs",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"coverageReporters": ["text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "tsc --noEmit --project {projectRoot}/tsconfig.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"packages/twenty-apps/community/fireflies/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"fix": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
/**
|
||||
* Migration script to add custom fields to the Meeting object
|
||||
* Run this after: npx twenty-cli app sync packages/twenty-apps/fireflies
|
||||
*
|
||||
* Usage: yarn setup:fields
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
|
||||
const API_KEY = process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error('❌ Error: TWENTY_API_KEY not found in .env file');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface RelationCreationPayload {
|
||||
targetObjectMetadataId: string;
|
||||
targetFieldLabel: string;
|
||||
targetFieldIcon: string;
|
||||
type: 'ONE_TO_MANY' | 'MANY_TO_ONE';
|
||||
}
|
||||
|
||||
interface FieldOption {
|
||||
value: string;
|
||||
label: string;
|
||||
position: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface FieldDefinition {
|
||||
type: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
isNullable?: boolean;
|
||||
relationCreationPayload?: RelationCreationPayload;
|
||||
options?: FieldOption[];
|
||||
}
|
||||
|
||||
// Meeting fields based on Fireflies GraphQL API transcript schema
|
||||
// See: https://docs.fireflies.ai/graphql-api/query/transcript
|
||||
// Note: Some fields require higher plans (Pro, Business, Enterprise)
|
||||
const MEETING_FIELDS: FieldDefinition[] = [
|
||||
// === Internal Twenty Relations ===
|
||||
{
|
||||
type: 'RELATION',
|
||||
name: 'note',
|
||||
label: 'Meeting Note',
|
||||
description: 'Related note with detailed meeting content',
|
||||
icon: 'IconNotes',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Basic Fields (All Plans) ===
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'firefliesMeetingId',
|
||||
label: 'Fireflies ID',
|
||||
description: 'Unique transcript ID from Fireflies (maps to: id)',
|
||||
icon: 'IconKey',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'DATE_TIME',
|
||||
name: 'meetingDate',
|
||||
label: 'Meeting Date',
|
||||
description: 'When the meeting occurred (maps to: date)',
|
||||
icon: 'IconCalendar',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'duration',
|
||||
label: 'Duration (minutes)',
|
||||
description: 'Meeting duration in minutes (maps to: duration)',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'organizerEmail',
|
||||
label: 'Organizer Email',
|
||||
description: 'Meeting organizer email (maps to: organizer_email)',
|
||||
icon: 'IconMail',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'transcriptUrl',
|
||||
label: 'Transcript URL',
|
||||
description: 'Link to full transcript (maps to: transcript_url)',
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'meetingLink',
|
||||
label: 'Meeting Link',
|
||||
description: 'Original meeting link (maps to: meeting_link)',
|
||||
icon: 'IconLink',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Pro+ Fields (summary, speakers, audio_url, transcript) ===
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'transcript',
|
||||
label: 'Full Transcript',
|
||||
description: 'Full meeting transcript with speaker names and timestamps [Pro+]',
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'overview',
|
||||
label: 'Overview',
|
||||
description: 'AI-generated meeting summary (maps to: summary.overview) [Pro+]',
|
||||
icon: 'IconFileDescription',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'notes',
|
||||
label: 'AI Notes',
|
||||
description: 'Detailed AI-generated meeting notes (maps to: summary.notes) [Pro+]',
|
||||
icon: 'IconNotes',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'keywords',
|
||||
label: 'Keywords',
|
||||
description: 'Key topics extracted (maps to: summary.keywords) [Pro+]',
|
||||
icon: 'IconTags',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'audioUrl',
|
||||
label: 'Audio URL',
|
||||
description: 'Link to audio recording (maps to: audio_url) [Pro+]',
|
||||
icon: 'IconHeadphones',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Business+ Fields (analytics, video_url, full summary) ===
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'meetingType',
|
||||
label: 'Meeting Type',
|
||||
description: 'AI-detected meeting type (maps to: summary.meeting_type) [Business+]',
|
||||
icon: 'IconTag',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'topics',
|
||||
label: 'Topics Discussed',
|
||||
description: 'Topics covered in meeting (maps to: summary.topics_discussed) [Business+]',
|
||||
icon: 'IconListDetails',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'actionItemsCount',
|
||||
label: 'Action Items',
|
||||
description: 'Number of action items (count of: summary.action_items) [Business+]',
|
||||
icon: 'IconCheckbox',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'positivePercent',
|
||||
label: 'Positive %',
|
||||
description: 'Positive sentiment % (maps to: analytics.sentiments.positive_pct) [Business+]',
|
||||
icon: 'IconThumbUp',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'negativePercent',
|
||||
label: 'Negative %',
|
||||
description: 'Negative sentiment % (maps to: analytics.sentiments.negative_pct) [Business+]',
|
||||
icon: 'IconThumbDown',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'neutralPercent',
|
||||
label: 'Neutral %',
|
||||
description: 'Neutral sentiment % (maps to: analytics.sentiments.neutral_pct) [Business+]',
|
||||
icon: 'IconMoodNeutral',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'videoUrl',
|
||||
label: 'Video URL',
|
||||
description: 'Link to video recording (maps to: video_url) [Business+]',
|
||||
icon: 'IconVideo',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Import Tracking Fields (Internal) ===
|
||||
{
|
||||
type: 'SELECT',
|
||||
name: 'importStatus',
|
||||
label: 'Import Status',
|
||||
description: 'Status of the Fireflies import',
|
||||
icon: 'IconCheck',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ value: 'SUCCESS', label: 'Success', position: 0, color: 'green' },
|
||||
{ value: 'PARTIAL', label: 'Partial', position: 1, color: 'blue' },
|
||||
{ value: 'FAILED', label: 'Failed', position: 2, color: 'red' },
|
||||
{ value: 'PENDING', label: 'Pending', position: 3, color: 'yellow' },
|
||||
{ value: 'RETRYING', label: 'Retrying', position: 4, color: 'orange' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'importError',
|
||||
label: 'Import Error',
|
||||
description: 'Error message if import failed',
|
||||
icon: 'IconAlertTriangle',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'DATE_TIME',
|
||||
name: 'lastImportAttempt',
|
||||
label: 'Last Import Attempt',
|
||||
description: 'When import was last attempted',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'importAttempts',
|
||||
label: 'Import Attempts',
|
||||
description: 'Number of import attempts',
|
||||
icon: 'IconRepeat',
|
||||
isNullable: true,
|
||||
},
|
||||
];
|
||||
|
||||
const graphqlRequest = async (query: string, variables: Record<string, unknown> = {}) => {
|
||||
const response = await fetch(`${SERVER_URL}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`GraphQL request failed (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (json.errors) {
|
||||
throw new Error(`GraphQL errors: ${JSON.stringify(json.errors, null, 2)}`);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
};
|
||||
|
||||
const findMeetingObject = async () => {
|
||||
const query = `
|
||||
query FindMeetingObject {
|
||||
objects(paging: { first: 200 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
nameSingular
|
||||
labelSingular
|
||||
labelPlural
|
||||
fields {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
label
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await graphqlRequest(query);
|
||||
const edges = data.objects?.edges || [];
|
||||
const meetingEdge = edges.find(
|
||||
(edge: any) => edge?.node?.nameSingular === 'meeting',
|
||||
);
|
||||
|
||||
if (!meetingEdge) {
|
||||
throw new Error('Meeting object not found. Please run "npx twenty-cli app sync" first.');
|
||||
}
|
||||
|
||||
return meetingEdge.node;
|
||||
};
|
||||
|
||||
const findNoteObject = async () => {
|
||||
const query = `
|
||||
query FindObjects {
|
||||
objects(paging: { first: 100 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
nameSingular
|
||||
labelSingular
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await graphqlRequest(query);
|
||||
const edges = data.objects?.edges || [];
|
||||
const noteEdge = edges.find(
|
||||
(edge: any) => edge?.node?.nameSingular === 'note',
|
||||
);
|
||||
|
||||
if (!noteEdge) {
|
||||
throw new Error('Note object not found.');
|
||||
}
|
||||
|
||||
return noteEdge.node;
|
||||
};
|
||||
|
||||
const createField = async (objectId: string, field: FieldDefinition) => {
|
||||
const mutation = `
|
||||
mutation CreateField($input: CreateOneFieldMetadataInput!) {
|
||||
createOneField(input: $input) {
|
||||
id
|
||||
name
|
||||
label
|
||||
type
|
||||
description
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const input = {
|
||||
field: {
|
||||
type: field.type,
|
||||
name: field.name,
|
||||
label: field.label,
|
||||
description: field.description,
|
||||
icon: field.icon || 'IconAbc',
|
||||
isNullable: field.isNullable !== false,
|
||||
isActive: true,
|
||||
isCustom: true,
|
||||
objectMetadataId: objectId,
|
||||
...(field.relationCreationPayload && {
|
||||
relationCreationPayload: field.relationCreationPayload,
|
||||
}),
|
||||
...(field.options && {
|
||||
options: field.options,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await graphqlRequest(mutation, { input });
|
||||
return data.createOneField;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message;
|
||||
if (
|
||||
message.includes('already exists') ||
|
||||
message.includes('not available') ||
|
||||
message.includes('Duplicating')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
console.log('🚀 Adding custom fields to Meeting object...\n');
|
||||
|
||||
try {
|
||||
// Step 1: Find Meeting and Note objects
|
||||
console.log('📋 Finding Meeting object...');
|
||||
const meetingObject = await findMeetingObject();
|
||||
console.log(`✅ Found Meeting object: ${meetingObject.labelSingular ?? meetingObject.nameSingular ?? 'Meeting'} (ID: ${meetingObject.id})\n`);
|
||||
|
||||
console.log('📋 Finding Note object...');
|
||||
const noteObject = await findNoteObject();
|
||||
console.log(`✅ Found Note object: ${noteObject.labelSingular ?? noteObject.nameSingular ?? 'Note'} (ID: ${noteObject.id})\n`);
|
||||
|
||||
// Step 2: Update note field with relationCreationPayload
|
||||
const fieldsToCreate = MEETING_FIELDS.map(field => {
|
||||
if (field.name === 'note' && field.type === 'RELATION') {
|
||||
return {
|
||||
...field,
|
||||
relationCreationPayload: {
|
||||
targetObjectMetadataId: noteObject.id,
|
||||
targetFieldLabel: 'Meeting',
|
||||
targetFieldIcon: 'IconCalendarEvent',
|
||||
type: 'MANY_TO_ONE' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
return field;
|
||||
});
|
||||
|
||||
// Step 3: Check existing fields
|
||||
const existingFields = meetingObject.fields?.edges?.map((edge: any) => edge.node.name) || [];
|
||||
console.log(`📌 Existing fields: ${existingFields.join(', ')}\n`);
|
||||
|
||||
// Step 4: Create custom fields
|
||||
console.log('➕ Creating custom fields...\n');
|
||||
|
||||
let createdCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const field of fieldsToCreate) {
|
||||
try {
|
||||
if (existingFields.includes(field.name)) {
|
||||
console.log(` ⏭️ ${field.name} - already exists`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await createField(meetingObject.id, field);
|
||||
|
||||
if (result) {
|
||||
console.log(` ✅ ${field.name} - created successfully`);
|
||||
createdCount++;
|
||||
} else {
|
||||
console.log(` ⏭️ ${field.name} - skipped (already exists)`);
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(` ❌ ${field.name} - failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 Summary:');
|
||||
console.log(` ✅ Created: ${createdCount} fields`);
|
||||
console.log(` ⏭️ Skipped: ${skippedCount} fields`);
|
||||
console.log(` ❌ Failed: ${failedCount} fields`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
if (failedCount > 0) {
|
||||
console.log('\n⚠️ Some fields failed to create. Please check the errors above.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (createdCount === 0 && skippedCount === MEETING_FIELDS.length) {
|
||||
console.log('\n✨ All fields already exist. Nothing to do!\n');
|
||||
} else if (createdCount > 0) {
|
||||
console.log('\n✨ Custom fields added successfully!\n');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
// Serverless function entry point - re-exports from src/lib
|
||||
export { config, main } from '../../../src';
|
||||
export type {
|
||||
FirefliesMeetingData,
|
||||
FirefliesParticipant,
|
||||
FirefliesWebhookPayload,
|
||||
ProcessResult,
|
||||
SummaryFetchConfig,
|
||||
SummaryStrategy
|
||||
} from '../../../src';
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { HistoricalImporter } from '../historical-importer';
|
||||
import type { FirefliesMeetingData, SummaryFetchConfig } from '../types';
|
||||
|
||||
const summaryConfig: SummaryFetchConfig = {
|
||||
strategy: 'immediate_with_retry',
|
||||
retryAttempts: 1,
|
||||
retryDelay: 0,
|
||||
pollInterval: 0,
|
||||
maxPolls: 0,
|
||||
};
|
||||
|
||||
const sampleMeeting: FirefliesMeetingData = {
|
||||
id: 'm-1',
|
||||
title: 'Sample',
|
||||
date: new Date().toISOString(),
|
||||
duration: 30,
|
||||
participants: [],
|
||||
summary: { action_items: [], overview: '' },
|
||||
transcript_url: 'https://example.com',
|
||||
};
|
||||
|
||||
describe('HistoricalImporter', () => {
|
||||
const buildImporter = () => {
|
||||
const firefliesClient = {
|
||||
listTranscripts: jest.fn(),
|
||||
fetchMeetingDataWithRetry: jest.fn(),
|
||||
} as unknown as jest.Mocked<any>;
|
||||
|
||||
const twentyService = {
|
||||
findMeetingByFirefliesId: jest.fn(),
|
||||
matchParticipantsToContacts: jest.fn(),
|
||||
createContactsForUnmatched: jest.fn(),
|
||||
createNoteOnly: jest.fn(),
|
||||
createMeeting: jest.fn(),
|
||||
createNoteTarget: jest.fn(),
|
||||
} as unknown as jest.Mocked<any>;
|
||||
|
||||
return { firefliesClient, twentyService };
|
||||
};
|
||||
|
||||
it('skips meetings that already exist by firefliesMeetingId', async () => {
|
||||
const { firefliesClient, twentyService } = buildImporter();
|
||||
|
||||
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'existing' }]);
|
||||
twentyService.findMeetingByFirefliesId.mockResolvedValue({ id: 'twenty-id' });
|
||||
|
||||
const importer = new HistoricalImporter(firefliesClient, twentyService);
|
||||
const result = await importer.run(
|
||||
{},
|
||||
{ dryRun: false, autoCreateContacts: true, summaryConfig, plan: 'free' },
|
||||
);
|
||||
|
||||
expect(result.skippedExisting).toBe(1);
|
||||
expect(result.imported).toBe(0);
|
||||
expect(twentyService.createMeeting).not.toHaveBeenCalled();
|
||||
expect(result.statuses[0].status).toBe('skipped_existing');
|
||||
});
|
||||
|
||||
it('supports dry-run without writing to Twenty', async () => {
|
||||
const { firefliesClient, twentyService } = buildImporter();
|
||||
|
||||
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'm-2' }]);
|
||||
firefliesClient.fetchMeetingDataWithRetry.mockResolvedValue({
|
||||
data: sampleMeeting,
|
||||
summaryReady: false,
|
||||
});
|
||||
twentyService.findMeetingByFirefliesId.mockResolvedValue(undefined);
|
||||
|
||||
const importer = new HistoricalImporter(firefliesClient, twentyService);
|
||||
const result = await importer.run(
|
||||
{},
|
||||
{ dryRun: true, autoCreateContacts: false, summaryConfig, plan: 'free' },
|
||||
);
|
||||
|
||||
expect(result.imported).toBe(1);
|
||||
expect(result.summaryPending).toBe(1);
|
||||
expect(twentyService.createMeeting).not.toHaveBeenCalled();
|
||||
expect(twentyService.createNoteOnly).not.toHaveBeenCalled();
|
||||
expect(result.statuses).toHaveLength(1);
|
||||
expect(result.statuses[0].status).toBe('pending_summary');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
import { WebhookHandler } from '../webhook-handler';
|
||||
|
||||
describe('WebhookHandler log capture', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
FIREFLIES_WEBHOOK_SECRET: 'testsecret',
|
||||
FIREFLIES_API_KEY: '',
|
||||
TWENTY_API_KEY: '',
|
||||
CAPTURE_LOGS: 'false',
|
||||
LOG_LEVEL: 'silent',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('includes debug logs in response when CAPTURE_LOGS is true', async () => {
|
||||
process.env.CAPTURE_LOGS = 'true';
|
||||
|
||||
const handler = new WebhookHandler();
|
||||
const result = await handler.handle(null);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(Array.isArray(result.debug)).toBe(true);
|
||||
});
|
||||
|
||||
it('omits debug logs when CAPTURE_LOGS is false', async () => {
|
||||
process.env.CAPTURE_LOGS = 'false';
|
||||
|
||||
const handler = new WebhookHandler();
|
||||
const result = await handler.handle(null);
|
||||
|
||||
expect(result.debug).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,823 +0,0 @@
|
||||
import { createLogger } from './logger';
|
||||
import {
|
||||
FIREFLIES_PLANS,
|
||||
type FirefliesMeetingData,
|
||||
type FirefliesParticipant,
|
||||
type FirefliesPlan,
|
||||
type FirefliesTranscriptListItem,
|
||||
type FirefliesTranscriptListOptions,
|
||||
type SummaryFetchConfig
|
||||
} from './types';
|
||||
|
||||
const logger = createLogger('fireflies-api');
|
||||
|
||||
export class FirefliesApiClient {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY is required');
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async listTranscripts(options: FirefliesTranscriptListOptions = {}): Promise<FirefliesTranscriptListItem[]> {
|
||||
const {
|
||||
organizers,
|
||||
participants,
|
||||
hostEmail,
|
||||
participantEmail,
|
||||
userId,
|
||||
channelId,
|
||||
mine,
|
||||
fromDate,
|
||||
toDate,
|
||||
pageSize = 50,
|
||||
maxRecords = 500,
|
||||
} = options;
|
||||
|
||||
const sanitizedOrganizers = organizers?.filter(Boolean);
|
||||
const sanitizedParticipants = participants?.filter(Boolean);
|
||||
|
||||
const transcripts: FirefliesTranscriptListItem[] = [];
|
||||
let skip = options.skip ?? 0;
|
||||
const limit = options.limit ?? pageSize;
|
||||
|
||||
const baseQuery = `
|
||||
query Transcripts(
|
||||
$limit: Int
|
||||
$skip: Int
|
||||
$hostEmail: String
|
||||
$participantEmail: String
|
||||
$organizers: [String!]
|
||||
$participants: [String!]
|
||||
$userId: String
|
||||
$channelId: String
|
||||
$mine: Boolean
|
||||
$date: Float
|
||||
) {
|
||||
transcripts(
|
||||
limit: $limit
|
||||
skip: $skip
|
||||
host_email: $hostEmail
|
||||
participant_email: $participantEmail
|
||||
organizers: $organizers
|
||||
participants: $participants
|
||||
user_id: $userId
|
||||
channel_id: $channelId
|
||||
mine: $mine
|
||||
date: $date
|
||||
) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
organizer_email
|
||||
participants
|
||||
transcript_url
|
||||
meeting_link
|
||||
meeting_info { summary_status }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
while (transcripts.length < maxRecords) {
|
||||
const pageVariables = {
|
||||
limit,
|
||||
skip,
|
||||
hostEmail,
|
||||
participantEmail,
|
||||
organizers: sanitizedOrganizers,
|
||||
participants: sanitizedParticipants,
|
||||
userId,
|
||||
channelId,
|
||||
mine,
|
||||
date: fromDate,
|
||||
};
|
||||
|
||||
const page = await this.executeTranscriptListQuery(baseQuery, pageVariables);
|
||||
const normalized = page
|
||||
.map((item) => {
|
||||
const normalizedDate = this.normalizeDate(item.date);
|
||||
return {
|
||||
id: (item.id as string) || '',
|
||||
title: (item.title as string) || 'Untitled Meeting',
|
||||
date: normalizedDate,
|
||||
duration: (item.duration as number) || 0,
|
||||
organizer_email: item.organizer_email as string | undefined,
|
||||
participants: Array.isArray(item.participants)
|
||||
? (item.participants as string[])
|
||||
: undefined,
|
||||
transcript_url: item.transcript_url as string | undefined,
|
||||
meeting_link: item.meeting_link as string | undefined,
|
||||
summary_status: (item.meeting_info as { summary_status?: string } | undefined)?.summary_status,
|
||||
};
|
||||
})
|
||||
.filter((item) => {
|
||||
if (toDate && item.date) {
|
||||
const itemTime = Date.parse(item.date);
|
||||
if (!Number.isNaN(itemTime) && itemTime > toDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
transcripts.push(...normalized);
|
||||
|
||||
if (page.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
skip += limit;
|
||||
}
|
||||
|
||||
if (transcripts.length > maxRecords) {
|
||||
return transcripts.slice(0, maxRecords);
|
||||
}
|
||||
|
||||
return transcripts;
|
||||
}
|
||||
|
||||
async fetchMeetingData(
|
||||
meetingId: string,
|
||||
options?: { timeout?: number; plan?: FirefliesPlan }
|
||||
): Promise<FirefliesMeetingData> {
|
||||
const plan = options?.plan ?? FIREFLIES_PLANS.FREE;
|
||||
const isPremiumPlan =
|
||||
plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE;
|
||||
|
||||
// Minimal query for free plans - only basic fields available on all plans
|
||||
// Note: audio_url requires Pro+, video_url requires Business+
|
||||
const freeQuery = `
|
||||
query GetTranscriptMinimal($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
transcript_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Standard query for pro plans - adds speakers, summary, sentences, and audio_url (Pro+)
|
||||
// Note: video_url requires Business+
|
||||
const proQuery = `
|
||||
query GetTranscriptBasic($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
speakers {
|
||||
name
|
||||
}
|
||||
sentences {
|
||||
index
|
||||
speaker_name
|
||||
text
|
||||
start_time
|
||||
end_time
|
||||
}
|
||||
summary {
|
||||
overview
|
||||
keywords
|
||||
action_items
|
||||
notes
|
||||
gist
|
||||
bullet_gist
|
||||
short_summary
|
||||
short_overview
|
||||
outline
|
||||
shorthand_bullet
|
||||
}
|
||||
meeting_info {
|
||||
summary_status
|
||||
}
|
||||
transcript_url
|
||||
audio_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Full query for business/enterprise - includes all fields
|
||||
const businessQuery = `
|
||||
query GetTranscriptFull($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
analytics {
|
||||
sentiments {
|
||||
positive_pct
|
||||
negative_pct
|
||||
neutral_pct
|
||||
}
|
||||
categories {
|
||||
questions
|
||||
tasks
|
||||
metrics
|
||||
date_times
|
||||
}
|
||||
speakers {
|
||||
speaker_id
|
||||
name
|
||||
duration
|
||||
word_count
|
||||
longest_monologue
|
||||
filler_words
|
||||
questions
|
||||
words_per_minute
|
||||
}
|
||||
}
|
||||
meeting_attendees {
|
||||
displayName
|
||||
email
|
||||
phoneNumber
|
||||
name
|
||||
location
|
||||
}
|
||||
meeting_attendance {
|
||||
name
|
||||
join_time
|
||||
leave_time
|
||||
}
|
||||
speakers {
|
||||
name
|
||||
}
|
||||
sentences {
|
||||
index
|
||||
speaker_name
|
||||
text
|
||||
start_time
|
||||
end_time
|
||||
ai_filters {
|
||||
task
|
||||
question
|
||||
sentiment
|
||||
}
|
||||
}
|
||||
summary {
|
||||
action_items
|
||||
overview
|
||||
keywords
|
||||
notes
|
||||
gist
|
||||
bullet_gist
|
||||
short_summary
|
||||
short_overview
|
||||
outline
|
||||
shorthand_bullet
|
||||
topics_discussed
|
||||
meeting_type
|
||||
transcript_chapters
|
||||
}
|
||||
meeting_info {
|
||||
summary_status
|
||||
}
|
||||
transcript_url
|
||||
audio_url
|
||||
video_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Select query based on plan
|
||||
const queryToUse = isPremiumPlan ? businessQuery :
|
||||
(plan === FIREFLIES_PLANS.PRO ? proQuery : freeQuery);
|
||||
|
||||
const planFeatures = {
|
||||
[FIREFLIES_PLANS.FREE]: 'basic fields only (no summary, no audio/video)',
|
||||
[FIREFLIES_PLANS.PRO]: 'summary, speakers, audio_url',
|
||||
[FIREFLIES_PLANS.BUSINESS]: 'full access including analytics, video_url',
|
||||
[FIREFLIES_PLANS.ENTERPRISE]: 'full access including analytics, video_url',
|
||||
};
|
||||
logger.debug(`using ${plan} plan query (${planFeatures[plan]})`);
|
||||
|
||||
try {
|
||||
return await this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: queryToUse,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Detect plan-specific errors
|
||||
const requiresBusiness = message.toLowerCase().includes('business or higher');
|
||||
const requiresPro = message.toLowerCase().includes('pro or higher');
|
||||
const planError = requiresBusiness || requiresPro ||
|
||||
message.toLowerCase().includes('higher plan') ||
|
||||
message.includes('Cannot query field');
|
||||
|
||||
// Fallback cascade: business -> pro -> free
|
||||
if (planError) {
|
||||
if (isPremiumPlan) {
|
||||
logger.warn(`Plan limitation detected (configured: ${plan}), falling back to pro query`);
|
||||
try {
|
||||
return await this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: proQuery,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
} catch (proError) {
|
||||
const proMessage = proError instanceof Error ? proError.message : String(proError);
|
||||
if (proMessage.toLowerCase().includes('plan') || proMessage.includes('Cannot query field')) {
|
||||
logger.warn('Pro query also failed, falling back to minimal free query');
|
||||
return this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: freeQuery,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
}
|
||||
throw proError;
|
||||
}
|
||||
} else if (plan === FIREFLIES_PLANS.PRO) {
|
||||
logger.warn(`Pro plan query failed (${requiresBusiness ? 'requires Business+' : 'unknown restriction'}), falling back to free query`);
|
||||
return this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: freeQuery,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
} else {
|
||||
// Already using free query - some field might still be restricted
|
||||
logger.error(
|
||||
'Fireflies API rejected the minimal free query. This may indicate: ' +
|
||||
'1) The transcript ID is invalid, or ' +
|
||||
'2) Your API key does not have access to this transcript, or ' +
|
||||
'3) An unexpected API restriction : open an issue'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMeetingDataWithRetry(
|
||||
meetingId: string,
|
||||
config: SummaryFetchConfig,
|
||||
plan: FirefliesPlan = FIREFLIES_PLANS.FREE
|
||||
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
|
||||
// immediate_only: single attempt, no retries
|
||||
if (config.strategy === 'immediate_only') {
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
logger.debug(`summary ready: ${ready}`);
|
||||
return { data: meetingData, summaryReady: ready };
|
||||
}
|
||||
|
||||
// immediate_with_retry: retry with linear backoff
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
|
||||
|
||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||
try {
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
|
||||
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
|
||||
|
||||
if (ready) {
|
||||
return { data: meetingData, summaryReady: true };
|
||||
}
|
||||
|
||||
if (attempt < config.retryAttempts) {
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
} else {
|
||||
logger.debug(`max retries reached, returning partial data`);
|
||||
return { data: meetingData, summaryReady: false };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
|
||||
|
||||
if (attempt === config.retryAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`retrying in ${delayMs}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch meeting data after retries');
|
||||
}
|
||||
|
||||
private async executeTranscriptQuery({
|
||||
meetingId,
|
||||
query,
|
||||
timeout,
|
||||
}: {
|
||||
meetingId: string;
|
||||
query: string;
|
||||
timeout?: number;
|
||||
}): Promise<FirefliesMeetingData> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.fireflies.ai/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { transcriptId: meetingId },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetails = `Fireflies API request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.text();
|
||||
if (errorBody) {
|
||||
errorDetails += `: ${errorBody}`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore if we can't read the response body
|
||||
}
|
||||
throw new Error(errorDetails);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
data?: { transcript?: Record<string, unknown> };
|
||||
errors?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
if (json.errors && json.errors.length > 0) {
|
||||
throw new Error(`Fireflies API error: ${json.errors[0]?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const transcript = json.data?.transcript;
|
||||
if (!transcript) {
|
||||
throw new Error('Invalid response from Fireflies API: missing transcript data');
|
||||
}
|
||||
|
||||
return this.transformMeetingData(transcript, meetingId);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private isSummaryReady(meetingData: FirefliesMeetingData): boolean {
|
||||
return (
|
||||
(meetingData.summary?.action_items?.length > 0) ||
|
||||
(meetingData.summary?.overview?.length > 0) ||
|
||||
meetingData.summary_status === 'completed'
|
||||
);
|
||||
}
|
||||
|
||||
private extractAllParticipants(transcript: Record<string, unknown>): FirefliesParticipant[] {
|
||||
const participantsWithEmails: FirefliesParticipant[] = [];
|
||||
const participantsNameOnly: FirefliesParticipant[] = [];
|
||||
|
||||
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
|
||||
logger.debug('participants field:', JSON.stringify(transcript.participants));
|
||||
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
|
||||
logger.debug('speakers field:', (transcript.speakers as Array<{ name: string }>)?.map((s) => s.name));
|
||||
logger.debug('meeting_attendance field:', (transcript.meeting_attendance as Array<{ name: string }>)?.map((a) => a.name));
|
||||
logger.debug('organizer_email:', transcript.organizer_email);
|
||||
|
||||
const isEmail = (str: string): boolean => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
|
||||
};
|
||||
|
||||
const isDuplicate = (name: string, email: string): boolean => {
|
||||
const nameLower = name.toLowerCase().trim();
|
||||
const emailLower = email.toLowerCase().trim();
|
||||
|
||||
return participantsWithEmails.some(p =>
|
||||
p.name.toLowerCase().trim() === nameLower ||
|
||||
(email && p.email.toLowerCase() === emailLower)
|
||||
) || participantsNameOnly.some(p =>
|
||||
p.name.toLowerCase().trim() === nameLower
|
||||
);
|
||||
};
|
||||
|
||||
// 1. Extract from legacy participants field (with emails)
|
||||
if (transcript.participants && Array.isArray(transcript.participants)) {
|
||||
transcript.participants.forEach((participant: string) => {
|
||||
const parts = participant.split(',').map(p => p.trim());
|
||||
|
||||
parts.forEach(part => {
|
||||
const emailMatch = part.match(/<([^>]+)>/);
|
||||
const email = emailMatch ? emailMatch[1] : '';
|
||||
const name = emailMatch
|
||||
? part.substring(0, part.indexOf('<')).trim()
|
||||
: part.trim();
|
||||
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping participant with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate(name, email)) {
|
||||
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else if (name) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extract from meeting_attendees field (structured)
|
||||
if (transcript.meeting_attendees && Array.isArray(transcript.meeting_attendees)) {
|
||||
transcript.meeting_attendees.forEach((attendee: Record<string, unknown>) => {
|
||||
const name = (attendee.displayName || attendee.name || '') as string;
|
||||
const email = (attendee.email || '') as string;
|
||||
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping attendee with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, email)) {
|
||||
if (email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Extract from speakers field (name only)
|
||||
if (transcript.speakers && Array.isArray(transcript.speakers)) {
|
||||
transcript.speakers.forEach((speaker: Record<string, unknown>) => {
|
||||
const name = (speaker.name || '') as string;
|
||||
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping speaker with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Extract from meeting_attendance field (name only)
|
||||
if (transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
|
||||
transcript.meeting_attendance.forEach((attendance: Record<string, unknown>) => {
|
||||
const name = (attendance.name || '') as string;
|
||||
|
||||
if (isEmail(name) || name.includes(',')) {
|
||||
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Add organizer email if available and not already included
|
||||
const organizerEmail = transcript.organizer_email as string | undefined;
|
||||
if (organizerEmail) {
|
||||
const existsWithEmail = participantsWithEmails.some(p =>
|
||||
p.email.toLowerCase() === organizerEmail.toLowerCase()
|
||||
);
|
||||
|
||||
if (!existsWithEmail) {
|
||||
let organizerName = '';
|
||||
|
||||
const emailUsername = organizerEmail.split('@')[0].toLowerCase();
|
||||
const emailNameVariations = [emailUsername];
|
||||
|
||||
if (transcript.speakers && Array.isArray(transcript.speakers)) {
|
||||
const potentialOrganizerSpeaker = transcript.speakers.find((speaker: Record<string, unknown>) => {
|
||||
const name = ((speaker.name || '') as string).toLowerCase();
|
||||
return emailNameVariations.some(variation =>
|
||||
name.includes(variation) || variation.includes(name)
|
||||
);
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (potentialOrganizerSpeaker) {
|
||||
organizerName = potentialOrganizerSpeaker.name as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (!organizerName && transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
|
||||
const potentialOrganizerAttendance = transcript.meeting_attendance.find((attendance: Record<string, unknown>) => {
|
||||
const name = ((attendance.name || '') as string).toLowerCase();
|
||||
return emailNameVariations.some(variation =>
|
||||
name.includes(variation) || variation.includes(name)
|
||||
);
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (potentialOrganizerAttendance) {
|
||||
organizerName = potentialOrganizerAttendance.name as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (organizerName) {
|
||||
participantsWithEmails.push({ name: organizerName, email: organizerEmail });
|
||||
|
||||
const nameIndex = participantsNameOnly.findIndex(p =>
|
||||
p.name.toLowerCase().includes(organizerName.toLowerCase()) ||
|
||||
organizerName.toLowerCase().includes(p.name.toLowerCase())
|
||||
);
|
||||
if (nameIndex !== -1) {
|
||||
participantsNameOnly.splice(nameIndex, 1);
|
||||
}
|
||||
} else {
|
||||
participantsWithEmails.push({ name: 'Meeting Organizer', email: organizerEmail });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
|
||||
|
||||
logger.debug('=== EXTRACTED PARTICIPANTS ===');
|
||||
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
|
||||
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
|
||||
logger.debug('Total:', allParticipants.length);
|
||||
|
||||
return allParticipants;
|
||||
}
|
||||
|
||||
private transformMeetingData(transcript: Record<string, unknown>, meetingId: string): FirefliesMeetingData {
|
||||
let dateString: string;
|
||||
if (transcript.date) {
|
||||
if (typeof transcript.date === 'number') {
|
||||
dateString = new Date(transcript.date).toISOString();
|
||||
} else if (typeof transcript.date === 'string') {
|
||||
const parsed = Number(transcript.date);
|
||||
if (!isNaN(parsed)) {
|
||||
dateString = new Date(parsed).toISOString();
|
||||
} else {
|
||||
dateString = transcript.date;
|
||||
}
|
||||
} else {
|
||||
dateString = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
dateString = new Date().toISOString();
|
||||
}
|
||||
|
||||
const summary = transcript.summary as Record<string, unknown> | undefined;
|
||||
const analytics = transcript.analytics as Record<string, unknown> | undefined;
|
||||
const sentiments = analytics?.sentiments as Record<string, number> | undefined;
|
||||
const categories = analytics?.categories as Record<string, number> | undefined;
|
||||
const speakersAnalytics = analytics?.speakers as Array<Record<string, unknown>> | undefined;
|
||||
const meetingInfo = transcript.meeting_info as Record<string, unknown> | undefined;
|
||||
|
||||
// Transform sentences array
|
||||
const rawSentences = transcript.sentences as Array<Record<string, unknown>> | undefined;
|
||||
const sentences = rawSentences?.map(s => ({
|
||||
index: (s.index as number) || 0,
|
||||
speaker_name: (s.speaker_name as string) || 'Unknown',
|
||||
text: (s.text as string) || '',
|
||||
start_time: (s.start_time as string) || '0',
|
||||
end_time: (s.end_time as string) || '0',
|
||||
ai_filters: s.ai_filters as { task?: boolean; question?: boolean; sentiment?: string } | undefined,
|
||||
}));
|
||||
|
||||
// Transform speaker analytics
|
||||
const speakers = speakersAnalytics?.map(sp => ({
|
||||
speaker_id: (sp.speaker_id as string) || '',
|
||||
name: (sp.name as string) || 'Unknown',
|
||||
duration: (sp.duration as number) || 0,
|
||||
word_count: (sp.word_count as number) || 0,
|
||||
longest_monologue: (sp.longest_monologue as number) || 0,
|
||||
filler_words: (sp.filler_words as number) || 0,
|
||||
questions: (sp.questions as number) || 0,
|
||||
words_per_minute: (sp.words_per_minute as number) || 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: (transcript.id as string) || meetingId,
|
||||
title: (transcript.title as string) || 'Untitled Meeting',
|
||||
date: dateString,
|
||||
duration: (transcript.duration as number) || 0,
|
||||
participants: this.extractAllParticipants(transcript),
|
||||
organizer_email: transcript.organizer_email as string | undefined,
|
||||
sentences,
|
||||
summary: {
|
||||
// action_items can be string or array - normalize to array
|
||||
action_items: Array.isArray(summary?.action_items)
|
||||
? summary.action_items as string[]
|
||||
: (typeof summary?.action_items === 'string' && summary.action_items.trim()
|
||||
? summary.action_items.split('\n').filter((item: string) => item.trim())
|
||||
: []),
|
||||
overview: (summary?.overview as string) || '',
|
||||
notes: summary?.notes as string | undefined,
|
||||
gist: summary?.gist as string | undefined,
|
||||
bullet_gist: summary?.bullet_gist as string | undefined,
|
||||
short_summary: summary?.short_summary as string | undefined,
|
||||
short_overview: summary?.short_overview as string | undefined,
|
||||
outline: summary?.outline as string | undefined,
|
||||
shorthand_bullet: summary?.shorthand_bullet as string | undefined,
|
||||
keywords: summary?.keywords as string[] | undefined,
|
||||
topics_discussed: summary?.topics_discussed as string[] | undefined,
|
||||
meeting_type: summary?.meeting_type as string | undefined,
|
||||
transcript_chapters: summary?.transcript_chapters as string[] | undefined,
|
||||
},
|
||||
analytics: (sentiments || categories || speakers) ? {
|
||||
sentiments: sentiments ? {
|
||||
positive_pct: sentiments.positive_pct || 0,
|
||||
negative_pct: sentiments.negative_pct || 0,
|
||||
neutral_pct: sentiments.neutral_pct || 0,
|
||||
} : undefined,
|
||||
categories: categories ? {
|
||||
questions: categories.questions || 0,
|
||||
tasks: categories.tasks || 0,
|
||||
metrics: categories.metrics || 0,
|
||||
date_times: categories.date_times || 0,
|
||||
} : undefined,
|
||||
speakers,
|
||||
} : undefined,
|
||||
meeting_info: meetingInfo ? {
|
||||
summary_status: meetingInfo.summary_status as string | undefined,
|
||||
} : undefined,
|
||||
// URLs by plan availability:
|
||||
transcript_url: (transcript.transcript_url as string) || `https://app.fireflies.ai/view/${meetingId}`,
|
||||
audio_url: transcript.audio_url as string | undefined, // Pro+
|
||||
video_url: transcript.video_url as string | undefined, // Business+
|
||||
meeting_link: transcript.meeting_link as string | undefined, // All plans
|
||||
summary_status: (meetingInfo?.summary_status as string) || (transcript.summary_status as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeTranscriptListQuery(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const response = await fetch('https://api.fireflies.ai/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`Fireflies transcripts request failed: ${response.status} ${errorBody}`);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
data?: { transcripts?: Array<Record<string, unknown>> };
|
||||
errors?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
if (json.errors && json.errors.length > 0) {
|
||||
const message = json.errors[0]?.message || 'Unknown error';
|
||||
throw new Error(`Fireflies API error: ${message}`);
|
||||
}
|
||||
|
||||
return json.data?.transcripts ?? [];
|
||||
}
|
||||
|
||||
private normalizeDate(dateValue: unknown): string | undefined {
|
||||
if (!dateValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof dateValue === 'number') {
|
||||
return new Date(dateValue).toISOString();
|
||||
}
|
||||
|
||||
if (typeof dateValue === 'string') {
|
||||
const parsed = Number(dateValue);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toISOString();
|
||||
}
|
||||
return dateValue;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import type { FirefliesMeetingData, FirefliesSentence, MeetingCreateInput } from './types';
|
||||
|
||||
export class MeetingFormatter {
|
||||
// Format timestamp from seconds to MM:SS
|
||||
private static formatTimestamp(timeStr: string): string {
|
||||
const seconds = parseFloat(timeStr);
|
||||
if (isNaN(seconds)) return '00:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format full transcript from sentences
|
||||
private static formatTranscript(sentences: FirefliesSentence[]): string {
|
||||
if (!sentences || sentences.length === 0) return '';
|
||||
|
||||
let transcript = '';
|
||||
let currentSpeaker = '';
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const timestamp = this.formatTimestamp(sentence.start_time);
|
||||
const speaker = sentence.speaker_name || 'Unknown';
|
||||
|
||||
// Add speaker header when speaker changes
|
||||
if (speaker !== currentSpeaker) {
|
||||
currentSpeaker = speaker;
|
||||
transcript += `\n**${speaker}** [${timestamp}]\n`;
|
||||
}
|
||||
|
||||
transcript += `${sentence.text} `;
|
||||
}
|
||||
|
||||
return transcript.trim();
|
||||
}
|
||||
|
||||
static formatNoteBody(meetingData: FirefliesMeetingData): string {
|
||||
const meetingDate = meetingData.date ? new Date(meetingData.date) : null;
|
||||
const hasValidDate = meetingDate instanceof Date && !Number.isNaN(meetingDate.getTime());
|
||||
const formattedDate = hasValidDate
|
||||
? meetingDate.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: 'Unknown date';
|
||||
const durationMinutes = Math.round(meetingData.duration);
|
||||
|
||||
let noteBody = `**Date:** ${formattedDate}\n`;
|
||||
noteBody += `**Duration:** ${durationMinutes} minutes\n`;
|
||||
|
||||
if (meetingData.participants.length > 0) {
|
||||
const participantNames = meetingData.participants.map(p => p.name).join(', ');
|
||||
noteBody += `**Participants:** ${participantNames}\n`;
|
||||
}
|
||||
|
||||
// Overview section
|
||||
if (meetingData.summary?.overview) {
|
||||
noteBody += `\n## Overview\n${meetingData.summary.overview}\n`;
|
||||
}
|
||||
|
||||
// Detailed AI Notes (the rich content from Fireflies)
|
||||
if (meetingData.summary?.notes) {
|
||||
noteBody += `\n## Meeting Notes\n${meetingData.summary.notes}\n`;
|
||||
}
|
||||
|
||||
// Bullet gist with emojis (if available and different from notes)
|
||||
if (meetingData.summary?.bullet_gist && !meetingData.summary?.notes) {
|
||||
noteBody += `\n## Key Points\n${meetingData.summary.bullet_gist}\n`;
|
||||
}
|
||||
|
||||
// Meeting outline with timestamps (shorthand_bullet contains the timestamped outline)
|
||||
const outline = meetingData.summary?.outline || meetingData.summary?.shorthand_bullet;
|
||||
if (outline) {
|
||||
noteBody += `\n## Outline\n${outline}\n`;
|
||||
}
|
||||
|
||||
// Key topics
|
||||
if (meetingData.summary?.topics_discussed?.length) {
|
||||
noteBody += `\n## Key Topics\n`;
|
||||
meetingData.summary.topics_discussed.forEach(topic => {
|
||||
noteBody += `- ${topic}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Action items
|
||||
if (meetingData.summary?.action_items?.length) {
|
||||
noteBody += `\n## Action Items\n`;
|
||||
meetingData.summary.action_items.forEach(item => {
|
||||
noteBody += `- [ ] ${item}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Insights section
|
||||
noteBody += `\n## Insights\n`;
|
||||
|
||||
if (meetingData.summary?.keywords?.length) {
|
||||
noteBody += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
|
||||
}
|
||||
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
noteBody += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
|
||||
}
|
||||
|
||||
if (meetingData.summary?.meeting_type) {
|
||||
noteBody += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
|
||||
}
|
||||
|
||||
// Speaker analytics (Business+)
|
||||
if (meetingData.analytics?.speakers?.length) {
|
||||
noteBody += `\n### Speaker Stats\n`;
|
||||
for (const speaker of meetingData.analytics.speakers) {
|
||||
const talkTime = Math.round(speaker.duration / 60);
|
||||
noteBody += `- **${speaker.name}**: ${talkTime} min talk time, ${speaker.word_count} words, ${speaker.questions} questions\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Meeting metrics (Business+)
|
||||
if (meetingData.analytics?.categories) {
|
||||
const cats = meetingData.analytics.categories;
|
||||
noteBody += `\n### Meeting Metrics\n`;
|
||||
noteBody += `- Questions asked: ${cats.questions}\n`;
|
||||
noteBody += `- Tasks identified: ${cats.tasks}\n`;
|
||||
if (cats.metrics > 0) noteBody += `- Metrics mentioned: ${cats.metrics}\n`;
|
||||
if (cats.date_times > 0) noteBody += `- Dates/times discussed: ${cats.date_times}\n`;
|
||||
}
|
||||
|
||||
// Resources section
|
||||
noteBody += `\n## Resources\n`;
|
||||
noteBody += `[View Full Transcript on Fireflies](${meetingData.transcript_url})\n`;
|
||||
|
||||
if (meetingData.video_url) {
|
||||
noteBody += `[Watch Video Recording](${meetingData.video_url})\n`;
|
||||
}
|
||||
|
||||
if (meetingData.audio_url) {
|
||||
noteBody += `[Listen to Audio](${meetingData.audio_url})\n`;
|
||||
}
|
||||
|
||||
if (meetingData.meeting_link) {
|
||||
noteBody += `[Original Meeting Link](${meetingData.meeting_link})\n`;
|
||||
}
|
||||
|
||||
return noteBody;
|
||||
}
|
||||
|
||||
static toMeetingCreateInput(
|
||||
meetingData: FirefliesMeetingData,
|
||||
noteId?: string
|
||||
): MeetingCreateInput {
|
||||
const durationMinutes = Math.round(meetingData.duration);
|
||||
const hasSummary = Boolean(meetingData.summary?.overview || meetingData.summary?.action_items?.length);
|
||||
const hasAnalytics = Boolean(meetingData.analytics?.sentiments);
|
||||
|
||||
// Build input object with only defined values (omit null fields)
|
||||
const input: MeetingCreateInput = {
|
||||
name: meetingData.title,
|
||||
meetingDate: meetingData.date,
|
||||
duration: durationMinutes,
|
||||
actionItemsCount: meetingData.summary?.action_items?.length || 0,
|
||||
firefliesMeetingId: meetingData.id,
|
||||
};
|
||||
|
||||
// Add direct relationship to note if noteId is provided
|
||||
if (noteId) {
|
||||
input.noteId = noteId;
|
||||
}
|
||||
|
||||
// Basic fields (All plans)
|
||||
if (meetingData.organizer_email) {
|
||||
input.organizerEmail = meetingData.organizer_email;
|
||||
}
|
||||
if (meetingData.transcript_url?.trim()) {
|
||||
input.transcriptUrl = {
|
||||
primaryLinkUrl: meetingData.transcript_url,
|
||||
primaryLinkLabel: 'View Transcript'
|
||||
};
|
||||
}
|
||||
if (meetingData.meeting_link?.trim()) {
|
||||
input.meetingLink = {
|
||||
primaryLinkUrl: meetingData.meeting_link,
|
||||
primaryLinkLabel: 'Join Meeting'
|
||||
};
|
||||
}
|
||||
|
||||
// Pro+ fields (transcript, summary, notes, keywords, audio)
|
||||
if (meetingData.sentences?.length) {
|
||||
input.transcript = this.formatTranscript(meetingData.sentences);
|
||||
}
|
||||
if (meetingData.summary?.overview) {
|
||||
input.overview = meetingData.summary.overview;
|
||||
}
|
||||
if (meetingData.summary?.notes) {
|
||||
input.notes = meetingData.summary.notes;
|
||||
}
|
||||
if (meetingData.summary?.keywords?.length) {
|
||||
input.keywords = meetingData.summary.keywords.join(', ');
|
||||
}
|
||||
if (meetingData.audio_url?.trim()) {
|
||||
input.audioUrl = {
|
||||
primaryLinkUrl: meetingData.audio_url,
|
||||
primaryLinkLabel: 'Listen to Audio'
|
||||
};
|
||||
}
|
||||
|
||||
// Business+ fields (analytics, video, detailed summary)
|
||||
if (meetingData.summary?.meeting_type) {
|
||||
input.meetingType = meetingData.summary.meeting_type;
|
||||
}
|
||||
if (meetingData.summary?.topics_discussed?.length) {
|
||||
input.topics = meetingData.summary.topics_discussed.join(', ');
|
||||
}
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
input.positivePercent = sentiments.positive_pct;
|
||||
input.negativePercent = sentiments.negative_pct;
|
||||
input.neutralPercent = sentiments.neutral_pct;
|
||||
}
|
||||
if (meetingData.video_url?.trim()) {
|
||||
input.videoUrl = {
|
||||
primaryLinkUrl: meetingData.video_url,
|
||||
primaryLinkLabel: 'Watch Video'
|
||||
};
|
||||
}
|
||||
|
||||
// Import status based on data completeness
|
||||
const isPartial = !hasSummary && !hasAnalytics;
|
||||
input.importStatus = isPartial ? 'PARTIAL' : 'SUCCESS';
|
||||
input.lastImportAttempt = new Date().toISOString();
|
||||
input.importAttempts = 1;
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
static toFailedMeetingCreateInput(
|
||||
meetingId: string,
|
||||
title: string,
|
||||
error: string,
|
||||
attempts: number = 1
|
||||
): MeetingCreateInput {
|
||||
const currentDate = new Date().toISOString();
|
||||
|
||||
return {
|
||||
name: title || `Failed Meeting Import - ${meetingId}`,
|
||||
meetingDate: currentDate,
|
||||
duration: 0,
|
||||
actionItemsCount: 0,
|
||||
firefliesMeetingId: meetingId,
|
||||
importStatus: 'FAILED',
|
||||
importError: error,
|
||||
lastImportAttempt: currentDate,
|
||||
importAttempts: attempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { type FirefliesApiClient } from './fireflies-api-client';
|
||||
import { MeetingFormatter } from './formatters';
|
||||
import { createLogger } from './logger';
|
||||
import { type TwentyCrmService } from './twenty-crm-service';
|
||||
import type {
|
||||
FirefliesPlan,
|
||||
FirefliesTranscriptListOptions,
|
||||
SummaryFetchConfig,
|
||||
} from './types';
|
||||
|
||||
const logger = createLogger('historical-importer');
|
||||
|
||||
export type HistoricalImportFilters = FirefliesTranscriptListOptions;
|
||||
|
||||
export type HistoricalImportOptions = {
|
||||
dryRun?: boolean;
|
||||
autoCreateContacts: boolean;
|
||||
summaryConfig: SummaryFetchConfig;
|
||||
plan: FirefliesPlan;
|
||||
};
|
||||
|
||||
export type HistoricalImportResult = {
|
||||
dryRun: boolean;
|
||||
totalListed: number;
|
||||
imported: number;
|
||||
skippedExisting: number;
|
||||
failed: Array<{ meetingId: string; reason: string }>;
|
||||
summaryPending: number;
|
||||
statuses: Array<{
|
||||
meetingId: string;
|
||||
title?: string;
|
||||
status: 'imported' | 'skipped_existing' | 'failed' | 'dry_run' | 'pending_summary';
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export class HistoricalImporter {
|
||||
private firefliesClient: FirefliesApiClient;
|
||||
private twentyService: TwentyCrmService;
|
||||
|
||||
constructor(firefliesClient: FirefliesApiClient, twentyService: TwentyCrmService) {
|
||||
this.firefliesClient = firefliesClient;
|
||||
this.twentyService = twentyService;
|
||||
}
|
||||
|
||||
async run(
|
||||
filters: HistoricalImportFilters,
|
||||
options: HistoricalImportOptions,
|
||||
): Promise<HistoricalImportResult> {
|
||||
const { dryRun = false, autoCreateContacts, summaryConfig, plan } = options;
|
||||
|
||||
logger.info('Listing Fireflies transcripts for historical import...');
|
||||
const transcripts = await this.firefliesClient.listTranscripts(filters);
|
||||
logger.info(`Found ${transcripts.length} transcript(s) to process`);
|
||||
|
||||
let imported = 0;
|
||||
let skippedExisting = 0;
|
||||
let summaryPending = 0;
|
||||
const failed: Array<{ meetingId: string; reason: string }> = [];
|
||||
const statuses: HistoricalImportResult['statuses'] = [];
|
||||
|
||||
for (const transcript of transcripts) {
|
||||
const meetingId = transcript.id;
|
||||
|
||||
try {
|
||||
const existing = await this.twentyService.findMeetingByFirefliesId(meetingId);
|
||||
if (existing) {
|
||||
logger.debug(`Skipping ${meetingId}: already exists in Twenty (${existing.id})`);
|
||||
skippedExisting += 1;
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: transcript.title,
|
||||
status: 'skipped_existing',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Fetching meeting ${meetingId} details`);
|
||||
const { data: meetingData, summaryReady } =
|
||||
await this.firefliesClient.fetchMeetingDataWithRetry(
|
||||
meetingId,
|
||||
summaryConfig,
|
||||
plan,
|
||||
);
|
||||
|
||||
const isPendingSummary = summaryReady === false;
|
||||
if (isPendingSummary) {
|
||||
summaryPending += 1;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
logger.info(`[dry-run] Would import meeting "${meetingData.title}" (${meetingId})`);
|
||||
imported += 1;
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: meetingData.title,
|
||||
status: isPendingSummary ? 'pending_summary' : 'dry_run',
|
||||
reason: isPendingSummary ? 'summary not ready' : undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { matchedContacts, unmatchedParticipants } =
|
||||
await this.twentyService.matchParticipantsToContacts(
|
||||
meetingData.participants,
|
||||
);
|
||||
|
||||
const newContactIds = autoCreateContacts
|
||||
? await this.twentyService.createContactsForUnmatched(unmatchedParticipants)
|
||||
: [];
|
||||
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
|
||||
|
||||
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
|
||||
const noteId = await this.twentyService.createNoteOnly(
|
||||
`Meeting: ${meetingData.title}`,
|
||||
noteBody,
|
||||
);
|
||||
|
||||
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
|
||||
const createdMeetingId = await this.twentyService.createMeeting(meetingInput);
|
||||
|
||||
for (const contactId of allContactIds) {
|
||||
await this.twentyService.createNoteTarget(noteId, contactId);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Imported meeting "${meetingData.title}" (${meetingId}) as ${createdMeetingId}`,
|
||||
);
|
||||
imported += 1;
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: meetingData.title,
|
||||
status: isPendingSummary ? 'pending_summary' : 'imported',
|
||||
reason: isPendingSummary ? 'summary not ready' : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to import meeting ${meetingId}: ${reason}`);
|
||||
failed.push({ meetingId, reason });
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: transcript.title,
|
||||
status: 'failed',
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
totalListed: transcripts.length,
|
||||
imported,
|
||||
skippedExisting,
|
||||
failed,
|
||||
summaryPending,
|
||||
statuses,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
// Main exports for the Fireflies integration
|
||||
export { config, main } from './receive-fireflies-notes';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
FirefliesMeetingData,
|
||||
FirefliesParticipant,
|
||||
FirefliesWebhookPayload,
|
||||
ProcessResult,
|
||||
SummaryFetchConfig,
|
||||
SummaryStrategy
|
||||
} from './types';
|
||||
|
||||
// Services (for advanced usage)
|
||||
export { FirefliesApiClient } from './fireflies-api-client';
|
||||
export { MeetingFormatter } from './formatters';
|
||||
export { TwentyCrmService } from './twenty-crm-service';
|
||||
export { WebhookHandler } from './webhook-handler';
|
||||
|
||||
// Objects
|
||||
export { Meeting } from './objects';
|
||||
|
||||
// Utilities
|
||||
export { createLogger } from './logger';
|
||||
export { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts, toBoolean } from './utils';
|
||||
export {
|
||||
getWebhookSecretFingerprint,
|
||||
isValidFirefliesPayload,
|
||||
verifyWebhookSignature
|
||||
} from './webhook-validator';
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
||||
|
||||
export type LoggerConfig = {
|
||||
logLevel: LogLevel;
|
||||
isTestEnvironment: boolean;
|
||||
captureForResponse: boolean;
|
||||
};
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
silent: 4,
|
||||
};
|
||||
|
||||
const loggerRegistry = new Set<AppLogger>();
|
||||
|
||||
export class AppLogger {
|
||||
private config: LoggerConfig;
|
||||
private context: string;
|
||||
private capturedLogs: string[] = [];
|
||||
|
||||
constructor(context: string) {
|
||||
this.context = context;
|
||||
this.config = {
|
||||
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
|
||||
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
|
||||
captureForResponse: process.env.CAPTURE_LOGS === 'true',
|
||||
};
|
||||
|
||||
// Silence logs in test environment unless explicitly overridden
|
||||
if (this.config.isTestEnvironment && process.env.LOG_LEVEL === undefined) {
|
||||
this.config.logLevel = 'silent';
|
||||
}
|
||||
}
|
||||
|
||||
private parseLogLevel(level: string): LogLevel {
|
||||
const normalizedLevel = level.toLowerCase() as LogLevel;
|
||||
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
|
||||
}
|
||||
|
||||
private safeStringify(value: unknown): string {
|
||||
try {
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '[unserializable]';
|
||||
}
|
||||
}
|
||||
|
||||
private captureLog(level: LogLevel, message: string, ...args: unknown[]): void {
|
||||
if (!this.config.captureForResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedMessage =
|
||||
args.length > 0
|
||||
? `${message} ${args.map((arg) => this.safeStringify(arg)).join(' ')}`
|
||||
: message;
|
||||
|
||||
this.capturedLogs.push(
|
||||
`[${timestamp}] [${level.toUpperCase()}] [${this.context}] ${formattedMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('debug', message, ...args);
|
||||
if (this.shouldLog('debug')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('info', message, ...args);
|
||||
if (this.shouldLog('info')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('warn', message, ...args);
|
||||
if (this.shouldLog('warn')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('error', message, ...args);
|
||||
if (this.shouldLog('error')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// For fatal errors, security issues, or data corruption - always visible
|
||||
critical(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('error', `CRITICAL: ${message}`, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
|
||||
}
|
||||
|
||||
getCapturedLogs(): string[] {
|
||||
return [...this.capturedLogs];
|
||||
}
|
||||
|
||||
clearCapturedLogs(): void {
|
||||
this.capturedLogs = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const createLogger = (context: string): AppLogger => {
|
||||
const logger = new AppLogger(context);
|
||||
loggerRegistry.add(logger);
|
||||
return logger;
|
||||
};
|
||||
|
||||
export const removeLogger = (logger: AppLogger): void => {
|
||||
loggerRegistry.delete(logger);
|
||||
};
|
||||
|
||||
export const getAllCapturedLogs = (): string[] => {
|
||||
const allLogs: string[] = [];
|
||||
for (const logger of loggerRegistry) {
|
||||
allLogs.push(...logger.getCapturedLogs());
|
||||
}
|
||||
return allLogs.sort();
|
||||
};
|
||||
|
||||
export const clearAllCapturedLogs = (): void => {
|
||||
for (const logger of loggerRegistry) {
|
||||
logger.clearCapturedLogs();
|
||||
}
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export { Meeting } from './meeting';
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { type FunctionConfig } from 'twenty-sdk';
|
||||
import type { ProcessResult } from './types';
|
||||
import { WebhookHandler } from './webhook-handler';
|
||||
|
||||
export const main = async (
|
||||
params: unknown,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ProcessResult> => {
|
||||
const handler = new WebhookHandler();
|
||||
return handler.handle(params, headers);
|
||||
};
|
||||
|
||||
export const config: FunctionConfig = {
|
||||
universalIdentifier: '2d3ea303-667c-4bbe-9e3d-db6ffb9d6c74',
|
||||
name: 'receive-fireflies-notes',
|
||||
description:
|
||||
'Receives Fireflies webhooks, fetches meeting summaries, and stores them in Twenty.',
|
||||
timeoutSeconds: 30,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'a2117dc1-7674-4c7e-9d70-9feb9820e9e8',
|
||||
type: 'route',
|
||||
path: '/webhook/fireflies',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
// Fireflies API Types
|
||||
export type FirefliesParticipant = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FirefliesWebhookPayload = {
|
||||
meetingId: string;
|
||||
eventType: string;
|
||||
clientReferenceId?: string;
|
||||
};
|
||||
|
||||
// Transcript sentence from Fireflies API
|
||||
export type FirefliesSentence = {
|
||||
index: number;
|
||||
speaker_name: string;
|
||||
text: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
ai_filters?: {
|
||||
task?: boolean;
|
||||
question?: boolean;
|
||||
sentiment?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Speaker analytics from Fireflies API (Business+)
|
||||
export type FirefliesSpeakerAnalytics = {
|
||||
speaker_id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
word_count: number;
|
||||
longest_monologue: number;
|
||||
filler_words: number;
|
||||
questions: number;
|
||||
words_per_minute: number;
|
||||
};
|
||||
|
||||
// Based on Fireflies GraphQL API transcript schema
|
||||
// See: https://docs.fireflies.ai/graphql-api/query/transcript
|
||||
export type FirefliesMeetingData = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
duration: number;
|
||||
participants: FirefliesParticipant[];
|
||||
organizer_email?: string;
|
||||
// Full transcript (Pro+)
|
||||
sentences?: FirefliesSentence[];
|
||||
summary: {
|
||||
// Pro+ fields
|
||||
action_items: string[];
|
||||
keywords?: string[];
|
||||
overview: string;
|
||||
notes?: string; // Detailed AI-generated meeting notes
|
||||
gist?: string; // 1-sentence summary
|
||||
bullet_gist?: string; // Bullet point summary with emojis
|
||||
short_summary?: string; // Single paragraph summary
|
||||
short_overview?: string; // Brief overview
|
||||
outline?: string; // Meeting outline with timestamps
|
||||
shorthand_bullet?: string;
|
||||
// Business+ fields
|
||||
topics_discussed?: string[];
|
||||
meeting_type?: string;
|
||||
transcript_chapters?: string[];
|
||||
};
|
||||
// Business+ analytics
|
||||
analytics?: {
|
||||
sentiments?: {
|
||||
positive_pct: number;
|
||||
negative_pct: number;
|
||||
neutral_pct: number;
|
||||
};
|
||||
categories?: {
|
||||
questions: number;
|
||||
tasks: number;
|
||||
metrics: number;
|
||||
date_times: number;
|
||||
};
|
||||
speakers?: FirefliesSpeakerAnalytics[];
|
||||
};
|
||||
meeting_info?: {
|
||||
summary_status?: string;
|
||||
};
|
||||
// URLs
|
||||
transcript_url: string;
|
||||
audio_url?: string; // Pro+
|
||||
video_url?: string; // Business+
|
||||
meeting_link?: string; // All plans
|
||||
summary_status?: string;
|
||||
};
|
||||
|
||||
export type FirefliesTranscriptListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
duration?: number;
|
||||
organizer_email?: string;
|
||||
participants?: string[];
|
||||
transcript_url?: string;
|
||||
meeting_link?: string;
|
||||
summary_status?: string;
|
||||
};
|
||||
|
||||
export type FirefliesTranscriptListOptions = {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
organizers?: string[];
|
||||
participants?: string[];
|
||||
hostEmail?: string;
|
||||
participantEmail?: string;
|
||||
userId?: string;
|
||||
channelId?: string;
|
||||
mine?: boolean;
|
||||
fromDate?: number;
|
||||
toDate?: number;
|
||||
pageSize?: number;
|
||||
maxRecords?: number;
|
||||
};
|
||||
|
||||
// Configuration Types
|
||||
export type SummaryStrategy = 'immediate_only' | 'immediate_with_retry' | 'delayed_polling' | 'basic_only';
|
||||
|
||||
export type SummaryFetchConfig = {
|
||||
strategy: SummaryStrategy;
|
||||
retryAttempts: number;
|
||||
retryDelay: number;
|
||||
pollInterval: number;
|
||||
maxPolls: number;
|
||||
};
|
||||
|
||||
export const FIREFLIES_PLANS = {
|
||||
FREE: 'free',
|
||||
PRO: 'pro',
|
||||
BUSINESS: 'business',
|
||||
ENTERPRISE: 'enterprise',
|
||||
} as const;
|
||||
|
||||
export type FirefliesPlan = typeof FIREFLIES_PLANS[keyof typeof FIREFLIES_PLANS];
|
||||
|
||||
export type WebhookConfig = {
|
||||
secret: string;
|
||||
apiUrl: string;
|
||||
};
|
||||
|
||||
// Processing Result Types
|
||||
export type ProcessResult = {
|
||||
success: boolean;
|
||||
meetingId?: string;
|
||||
noteIds?: string[];
|
||||
newContacts?: string[];
|
||||
errors?: string[];
|
||||
debug?: string[];
|
||||
summaryReady?: boolean;
|
||||
summaryPending?: boolean;
|
||||
enhancementScheduled?: boolean;
|
||||
actionItemsCount?: number;
|
||||
sentimentAnalysis?: {
|
||||
positive_pct: number;
|
||||
negative_pct: number;
|
||||
neutral_pct: number;
|
||||
};
|
||||
meetingType?: string;
|
||||
keyTopics?: string[];
|
||||
};
|
||||
|
||||
// Twenty CRM Types
|
||||
export type GraphQLResponse<T> = {
|
||||
data: T;
|
||||
errors?: Array<{
|
||||
message?: string;
|
||||
extensions?: { code?: string }
|
||||
}>;
|
||||
};
|
||||
|
||||
export type IdNode = { id: string };
|
||||
|
||||
export type FindMeetingResponse = {
|
||||
meetings: { edges: Array<{ node: IdNode }> };
|
||||
};
|
||||
|
||||
export type FindPeopleResponse = {
|
||||
people: { edges: Array<{ node: { id: string; emails: { primaryEmail: string } } }> };
|
||||
};
|
||||
|
||||
export type CreatePersonResponse = {
|
||||
createPerson: { id: string }
|
||||
};
|
||||
|
||||
export type CreateNoteResponse = {
|
||||
createNote: { id: string }
|
||||
};
|
||||
|
||||
export type CreateMeetingResponse = {
|
||||
createMeeting: { id: string }
|
||||
};
|
||||
|
||||
export type Contact = {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Twenty CRM Meeting custom field input
|
||||
// Maps to fields defined in add-meeting-fields.ts
|
||||
export type MeetingCreateInput = {
|
||||
name: string;
|
||||
noteId?: string | null;
|
||||
// Basic fields (All plans)
|
||||
meetingDate: string;
|
||||
duration: number;
|
||||
firefliesMeetingId: string;
|
||||
organizerEmail?: string | null;
|
||||
transcriptUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
meetingLink?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
// Pro+ fields
|
||||
transcript?: string | null;
|
||||
overview?: string | null;
|
||||
notes?: string | null;
|
||||
keywords?: string | null;
|
||||
audioUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
// Business+ fields
|
||||
meetingType?: string | null;
|
||||
topics?: string | null;
|
||||
actionItemsCount: number;
|
||||
positivePercent?: number | null;
|
||||
negativePercent?: number | null;
|
||||
neutralPercent?: number | null;
|
||||
videoUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
// Import tracking
|
||||
importStatus?: 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'PENDING' | 'RETRYING' | null;
|
||||
importError?: string | null;
|
||||
lastImportAttempt?: string | null;
|
||||
importAttempts?: number | null;
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { FIREFLIES_PLANS, type FirefliesPlan, type SummaryFetchConfig, type SummaryStrategy } from './types';
|
||||
|
||||
export const toBoolean = (value: string | undefined, defaultValue: boolean): boolean => {
|
||||
if (value === undefined) return defaultValue;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes';
|
||||
};
|
||||
|
||||
export const getApiUrl = (): string => {
|
||||
return process.env.SERVER_URL || 'http://localhost:3000';
|
||||
};
|
||||
|
||||
export const getSummaryFetchConfig = (): SummaryFetchConfig => {
|
||||
const strategy = (process.env.FIREFLIES_SUMMARY_STRATEGY as SummaryStrategy) || 'immediate_with_retry';
|
||||
|
||||
// Ultra-conservative defaults to respect 50 requests/day API limit
|
||||
// With 3 attempts at 15-minute intervals, max 3 API calls per webhook (45 minutes total)
|
||||
return {
|
||||
strategy,
|
||||
retryAttempts: parseInt(process.env.FIREFLIES_RETRY_ATTEMPTS || '3', 10),
|
||||
retryDelay: parseInt(process.env.FIREFLIES_RETRY_DELAY || '120000', 10), // 2 minutes
|
||||
pollInterval: parseInt(process.env.FIREFLIES_POLL_INTERVAL || '120000', 10), // 2 minutes
|
||||
maxPolls: parseInt(process.env.FIREFLIES_MAX_POLLS || '3', 10),
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldAutoCreateContacts = (): boolean => {
|
||||
return toBoolean(process.env.AUTO_CREATE_CONTACTS, true);
|
||||
};
|
||||
|
||||
export const getFirefliesPlan = (): FirefliesPlan => {
|
||||
const plan = (process.env.FIREFLIES_PLAN || FIREFLIES_PLANS.FREE).toLowerCase();
|
||||
if (plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE) {
|
||||
return plan as FirefliesPlan;
|
||||
}
|
||||
if (plan === FIREFLIES_PLANS.PRO) {
|
||||
return FIREFLIES_PLANS.PRO;
|
||||
}
|
||||
return FIREFLIES_PLANS.FREE;
|
||||
};
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import { FirefliesApiClient } from './fireflies-api-client';
|
||||
import { MeetingFormatter } from './formatters';
|
||||
import { clearAllCapturedLogs, createLogger, getAllCapturedLogs } from './logger';
|
||||
import { TwentyCrmService } from './twenty-crm-service';
|
||||
import type { FirefliesWebhookPayload, ProcessResult } from './types';
|
||||
import { getApiUrl, getFirefliesPlan, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
|
||||
import {
|
||||
getWebhookSecretFingerprint,
|
||||
isValidFirefliesPayload,
|
||||
verifyWebhookSignature
|
||||
} from './webhook-validator';
|
||||
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
export class WebhookHandler {
|
||||
private debug: string[] = [];
|
||||
private logger: ReturnType<typeof createLogger>;
|
||||
|
||||
constructor() {
|
||||
this.logger = createLogger('fireflies');
|
||||
}
|
||||
|
||||
private addDebugLogs(result: ProcessResult): ProcessResult {
|
||||
if (process.env.CAPTURE_LOGS === 'true') {
|
||||
const captured = getAllCapturedLogs();
|
||||
result.debug = [...this.debug, ...captured];
|
||||
} else {
|
||||
delete result.debug;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
|
||||
clearAllCapturedLogs();
|
||||
this.debug = [];
|
||||
|
||||
const result: ProcessResult = {
|
||||
success: false,
|
||||
noteIds: [],
|
||||
newContacts: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
this.logger.debug('invoked');
|
||||
this.logger.debug(`apiUrl=${getApiUrl()}`);
|
||||
const paramKeys =
|
||||
params && typeof params === 'object'
|
||||
? Object.keys(params as Record<string, unknown>)
|
||||
: [];
|
||||
this.debug.push(
|
||||
`paramsType=${typeof params}`,
|
||||
`paramKeys=${paramKeys.join(',') || 'none'}`
|
||||
);
|
||||
|
||||
// 0) Validate environment configuration
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
this.logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY environment variable is required');
|
||||
}
|
||||
if (!twentyApiKey) {
|
||||
this.logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('TWENTY_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
// 1) Parse and validate webhook payload and extract headers if wrapped together
|
||||
const { payload, extractedHeaders } = this.parsePayload(params);
|
||||
const finalHeaders = extractedHeaders || headers;
|
||||
|
||||
this.logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
|
||||
|
||||
// 2) Verify webhook signature
|
||||
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
|
||||
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
|
||||
this.logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
|
||||
|
||||
this.verifySignature(payload, finalHeaders, webhookSecret);
|
||||
this.logger.debug('signature verification: ok');
|
||||
|
||||
// 3) Fetch meeting data from Fireflies
|
||||
const summaryConfig = getSummaryFetchConfig();
|
||||
const firefliesPlan = getFirefliesPlan();
|
||||
this.logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
|
||||
this.logger.debug(`fetching meeting data from Fireflies API`);
|
||||
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
|
||||
payload.meetingId,
|
||||
summaryConfig,
|
||||
firefliesPlan
|
||||
);
|
||||
|
||||
this.logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
|
||||
|
||||
result.summaryReady = summaryReady;
|
||||
result.summaryPending = !summaryReady;
|
||||
|
||||
// Extract business intelligence
|
||||
if (summaryReady) {
|
||||
result.actionItemsCount = meetingData.summary.action_items.length;
|
||||
result.keyTopics = meetingData.summary.topics_discussed;
|
||||
result.meetingType = meetingData.summary.meeting_type;
|
||||
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
result.sentimentAnalysis = meetingData.analytics.sentiments;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Check for duplicate meetings
|
||||
const twentyService = new TwentyCrmService(
|
||||
twentyApiKey,
|
||||
getApiUrl()
|
||||
);
|
||||
|
||||
const existingMeetingById = await twentyService.findMeetingByFirefliesId(meetingData.id);
|
||||
if (existingMeetingById) {
|
||||
this.logger.debug(`meeting already exists by firefliesMeetingId id=${existingMeetingById.id}`);
|
||||
result.success = true;
|
||||
result.meetingId = existingMeetingById.id;
|
||||
return this.addDebugLogs(result);
|
||||
}
|
||||
|
||||
const existingMeetingByTitle = await twentyService.findExistingMeeting(meetingData.title);
|
||||
if (existingMeetingByTitle) {
|
||||
this.logger.debug(`meeting already exists by title id=${existingMeetingByTitle.id}`);
|
||||
result.success = true;
|
||||
result.meetingId = existingMeetingByTitle.id;
|
||||
return this.addDebugLogs(result);
|
||||
}
|
||||
this.logger.debug('no existing meeting found, proceeding');
|
||||
|
||||
// 5) Match participants to existing contacts
|
||||
this.logger.debug(`total participants from API: ${meetingData.participants.length}`);
|
||||
meetingData.participants.forEach((p, idx) => {
|
||||
this.logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
|
||||
meetingData.participants
|
||||
);
|
||||
this.logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
|
||||
|
||||
unmatchedParticipants.forEach((p, idx) => {
|
||||
this.logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
// 6) Optionally create contacts
|
||||
const autoCreate = shouldAutoCreateContacts();
|
||||
const newContactIds = autoCreate
|
||||
? await twentyService.createContactsForUnmatched(unmatchedParticipants)
|
||||
: [];
|
||||
result.newContacts = newContactIds;
|
||||
this.logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
|
||||
|
||||
// 7) Create note first (so we can link to it from the meeting)
|
||||
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
|
||||
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
|
||||
const noteId = await twentyService.createNoteOnly(
|
||||
`Meeting: ${meetingData.title}`,
|
||||
noteBody
|
||||
);
|
||||
result.noteIds = [noteId];
|
||||
this.logger.debug(`created note id=${noteId}`);
|
||||
|
||||
// 8) Create meeting with direct relationship to the note
|
||||
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
|
||||
this.logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
|
||||
result.meetingId = await twentyService.createMeeting(meetingInput);
|
||||
this.logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
|
||||
|
||||
// 9) Link note to participants (Meeting link is handled via the relation field)
|
||||
await this.linkNoteToParticipants(
|
||||
twentyService,
|
||||
noteId,
|
||||
allContactIds
|
||||
);
|
||||
this.logger.debug(`linked note to ${allContactIds.length} participants`);
|
||||
|
||||
result.success = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`error: ${message}`);
|
||||
result.errors?.push(message);
|
||||
|
||||
// Try to create a failed meeting record for tracking
|
||||
await this.createFailedMeetingRecord(params, message);
|
||||
}
|
||||
|
||||
return this.addDebugLogs(result);
|
||||
}
|
||||
|
||||
private parsePayload(params: unknown): { payload: FirefliesWebhookPayload; extractedHeaders?: Record<string, string> } {
|
||||
let normalizedParams = params;
|
||||
let extractedHeaders: Record<string, string> | undefined;
|
||||
|
||||
// Handle string-encoded params
|
||||
if (typeof normalizedParams === 'string') {
|
||||
this.logger.debug(`received params as string length=${normalizedParams.length}`);
|
||||
try {
|
||||
const parsed = JSON.parse(normalizedParams);
|
||||
normalizedParams = parsed;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsedKeys = Object.keys(parsed as Record<string, unknown>);
|
||||
this.logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
|
||||
}
|
||||
} catch (parseError) {
|
||||
this.logger.error(`error parsing string params: ${String(parseError)}`);
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle wrapped payloads and extract headers if present
|
||||
let payload: FirefliesWebhookPayload | undefined;
|
||||
if (isValidFirefliesPayload(normalizedParams)) {
|
||||
payload = normalizedParams as FirefliesWebhookPayload;
|
||||
} else if (normalizedParams && typeof normalizedParams === 'object') {
|
||||
const wrapper = normalizedParams as Record<string, unknown>;
|
||||
|
||||
// Extract headers if present in wrapper
|
||||
if (wrapper.headers && typeof wrapper.headers === 'object' && !Array.isArray(wrapper.headers)) {
|
||||
extractedHeaders = wrapper.headers as Record<string, string>;
|
||||
const headerKeys = Object.keys(extractedHeaders);
|
||||
this.logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
|
||||
}
|
||||
|
||||
const wrapperKeys = ['params', 'payload', 'body', 'data', 'event'];
|
||||
for (const key of wrapperKeys) {
|
||||
const candidate = wrapper[key];
|
||||
if (isValidFirefliesPayload(candidate)) {
|
||||
this.logger.debug(`detected payload under wrapper key "${key}"`);
|
||||
payload = candidate as FirefliesWebhookPayload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
this.logger.error('error: Invalid or missing webhook payload');
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
|
||||
// Log payload keys for debugging
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadKeys = Object.keys(payloadRecord);
|
||||
if (payloadKeys.length > 0) {
|
||||
this.logger.debug(`payload keys: ${payloadKeys.join(',')}`);
|
||||
}
|
||||
|
||||
return { payload, extractedHeaders };
|
||||
}
|
||||
|
||||
private verifySignature(
|
||||
payload: FirefliesWebhookPayload,
|
||||
headers: Record<string, string> | undefined,
|
||||
webhookSecret: string
|
||||
): void {
|
||||
// Extract headers
|
||||
const normalizedHeaders = headers || {};
|
||||
const headerKeys = Object.keys(normalizedHeaders);
|
||||
if (headerKeys.length > 0) {
|
||||
this.logger.debug(`header keys: ${headerKeys.join(',')}`);
|
||||
}
|
||||
this.debug.push(`headerKeys=${headerKeys.join(',') || 'none'}`);
|
||||
|
||||
const headerSignature = Object.entries(normalizedHeaders).find(
|
||||
([key]) => key.toLowerCase() === 'x-hub-signature',
|
||||
)?.[1];
|
||||
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadSignature =
|
||||
typeof payloadRecord['x-hub-signature'] === 'string'
|
||||
? (payloadRecord['x-hub-signature'] as string)
|
||||
: undefined;
|
||||
|
||||
if (payloadSignature) {
|
||||
this.logger.debug('found signature inside payload');
|
||||
}
|
||||
|
||||
const signature =
|
||||
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
|
||||
|
||||
const payloadForSignature =
|
||||
payloadSignature && 'x-hub-signature' in payloadRecord
|
||||
? Object.fromEntries(
|
||||
Object.entries(payloadRecord).filter(
|
||||
([key]) => key.toLowerCase() !== 'x-hub-signature',
|
||||
),
|
||||
)
|
||||
: payloadRecord;
|
||||
|
||||
const body =
|
||||
typeof normalizedHeaders['body'] === 'string'
|
||||
? normalizedHeaders['body']
|
||||
: JSON.stringify(payloadForSignature);
|
||||
|
||||
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
|
||||
if (!signatureCheck.isValid) {
|
||||
this.debug.push(
|
||||
`signatureProvided=${Boolean(signature)}`,
|
||||
`signatureMatched=${signatureCheck.isValid}`,
|
||||
`webhookSecretFingerprint=${getWebhookSecretFingerprint(webhookSecret)}`
|
||||
);
|
||||
this.logger.debug(
|
||||
`signature check failed. headerPresent=${Boolean(
|
||||
headerSignature,
|
||||
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
|
||||
);
|
||||
this.logger.debug(`provided signature present=${Boolean(signature)}`);
|
||||
this.logger.critical('Invalid webhook signature - potential security threat detected in production');
|
||||
throw new Error('Invalid webhook signature');
|
||||
}
|
||||
}
|
||||
|
||||
private async linkNoteToParticipants(
|
||||
twentyService: TwentyCrmService,
|
||||
noteId: string,
|
||||
contactIds: string[]
|
||||
): Promise<void> {
|
||||
for (const contactId of contactIds) {
|
||||
try {
|
||||
await twentyService.createNoteTarget(noteId, contactId);
|
||||
this.logger.debug(`linked note ${noteId} to person ${contactId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`failed to link note to person ${contactId}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
|
||||
try {
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
if (!twentyApiKey) {
|
||||
this.logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
let meetingId = 'unknown';
|
||||
let meetingTitle = 'Unknown Meeting';
|
||||
|
||||
const { payload } = this.parsePayload(params);
|
||||
if (payload?.meetingId) {
|
||||
meetingId = payload.meetingId;
|
||||
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
if (firefliesApiKey) {
|
||||
try {
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
|
||||
meetingTitle = meetingData.title || meetingTitle;
|
||||
} catch (fetchError) {
|
||||
this.logger.debug(
|
||||
`Could not fetch meeting title: ${
|
||||
fetchError instanceof Error ? fetchError.message : 'Unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
|
||||
const failedMeetingData = MeetingFormatter.toFailedMeetingCreateInput(
|
||||
meetingId,
|
||||
meetingTitle,
|
||||
error
|
||||
);
|
||||
|
||||
const failedMeetingId = await twentyService.createFailedMeeting(failedMeetingData);
|
||||
this.logger.debug(`Created failed meeting record: ${failedMeetingId}`);
|
||||
} catch (recordError) {
|
||||
this.logger.error(
|
||||
`Failed to create failed meeting record: ${
|
||||
recordError instanceof Error ? recordError.message : 'Unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,23 +0,0 @@
|
||||
# Last email interaction
|
||||
|
||||
Updates Last interaction and Interaction status fields based on last email date
|
||||
|
||||
## Requirements
|
||||
- an `apiKey` - go to Settings > API & Webhooks to generate one
|
||||
|
||||
## Setup
|
||||
1. Add and synchronize app
|
||||
```bash
|
||||
cd packages/twenty-apps/community/last-email-interaction
|
||||
yarn auth
|
||||
yarn sync
|
||||
```
|
||||
2. Go to Settings > Integrations > Last email interaction > Settings and add required variables
|
||||
|
||||
## Flow
|
||||
- Checks if fields are created, if not, creates them on fly
|
||||
- Extracts the datetime of message and calculates the last interaction status
|
||||
- Fetches all users and companies connected to them and updates their Last interaction and Interaction status fields
|
||||
|
||||
## Todo:
|
||||
- update app with generated Twenty object once extending objects is possible
|
||||
@@ -1,23 +0,0 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: '718ed9ab-53fc-49c8-8deb-0cff78ecf0d2',
|
||||
displayName: 'Last email interaction',
|
||||
description:
|
||||
'Updates Last interaction and Interaction status fields based on last received email',
|
||||
icon: "IconMailFast",
|
||||
applicationVariables: {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: 'aae3f523-4c1f-4805-b3ee-afeb676c381e',
|
||||
isSecret: true,
|
||||
description: 'Required to send requests to Twenty',
|
||||
},
|
||||
TWENTY_API_URL: {
|
||||
universalIdentifier: '6d19bb04-45bb-46aa-a4e5-4a2682c7b19d',
|
||||
isSecret: false,
|
||||
description: 'Optional, defaults to cloud API URL',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "last-email-interaction",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2",
|
||||
"twenty-sdk": "0.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
},
|
||||
"scripts": {
|
||||
"auth": "twenty auth login",
|
||||
"generate": "twenty app generate",
|
||||
"dev": "twenty app dev",
|
||||
"sync": "twenty app sync",
|
||||
"uninstall": "twenty app uninstall",
|
||||
"logs": "twenty app logs",
|
||||
"create-entity": "twenty app add",
|
||||
"help": "twenty --help"
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { type FunctionConfig } from 'twenty-sdk';
|
||||
|
||||
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '';
|
||||
const TWENTY_URL =
|
||||
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
|
||||
? `${process.env.TWENTY_API_URL}/rest`
|
||||
: 'https://api.twenty.com/rest';
|
||||
|
||||
const create_last_interaction = (id: string) => {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: `${TWENTY_URL}/metadata/fields`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
type: 'DATE_TIME',
|
||||
objectMetadataId: `${id}`,
|
||||
name: 'lastInteraction',
|
||||
label: 'Last interaction',
|
||||
description: 'Date when the last interaction happened',
|
||||
icon: 'IconCalendarClock',
|
||||
defaultValue: null,
|
||||
isNullable: true,
|
||||
settings: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const create_interaction_status = (id: string) => {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: `${TWENTY_URL}/metadata/fields`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
type: 'SELECT',
|
||||
objectMetadataId: `${id}`,
|
||||
name: 'interactionStatus',
|
||||
label: 'Interaction status',
|
||||
description: 'Indicates the health of relation',
|
||||
icon: 'IconProgress',
|
||||
defaultValue: null,
|
||||
isNullable: true,
|
||||
settings: {},
|
||||
options: [
|
||||
{
|
||||
color: 'green',
|
||||
label: 'Recent',
|
||||
value: 'RECENT',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
color: 'yellow',
|
||||
label: 'Active',
|
||||
value: 'ACTIVE',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
color: 'sky',
|
||||
label: 'Cooling',
|
||||
value: 'COOLING',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
label: 'Dormant',
|
||||
value: 'DORMANT',
|
||||
position: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const calculateStatus = (date: string) => {
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const now = Date.now();
|
||||
const messageDate = Date.parse(date);
|
||||
const deltaTime = now - messageDate;
|
||||
return deltaTime < 7 * day
|
||||
? 'RECENT'
|
||||
: deltaTime < 30 * day
|
||||
? 'ACTIVE'
|
||||
: deltaTime < 90 * day
|
||||
? 'COOLING'
|
||||
: 'DORMANT';
|
||||
};
|
||||
|
||||
const updateInteractionStatus = async (objectName: string, id: string, messageDate: string, status: string) => {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
url: `${TWENTY_URL}/${objectName}/${id}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
lastInteraction: messageDate,
|
||||
interactionStatus: status
|
||||
}
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
if (response.status === 200) {
|
||||
console.log('Successfully updated company last interaction field');
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRelatedCompanyId = async (id: string) => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/people/${id}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const req = await axios.request(options);
|
||||
if (req.status === 200 && req.data.person.companyId !== null && req.data.person.companyId !== undefined) {
|
||||
return req.data.person.companyId;
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const main = async (params: {
|
||||
properties: Record<string, any>;
|
||||
recordId: string;
|
||||
userId: string;
|
||||
}): Promise<object | undefined> => {
|
||||
if (TWENTY_API_KEY === '') {
|
||||
console.log("Function exited as API key or URL hasn't been set properly");
|
||||
return {};
|
||||
}
|
||||
const { properties, recordId } = params;
|
||||
// Check if fields are created
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/metadata/objects`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
const objects = response.data.data.objects;
|
||||
const company_object = objects.find(
|
||||
(object: any) => object.nameSingular === 'company',
|
||||
);
|
||||
const company_last_interaction = company_object.fields.find(
|
||||
(field: any) => field.name === 'lastInteraction',
|
||||
);
|
||||
const company_interaction_status = company_object.fields.find(
|
||||
(field: any) => field.name === 'interactionStatus',
|
||||
);
|
||||
const person_object = objects.find(
|
||||
(object: any) => object.nameSingular === 'person',
|
||||
);
|
||||
const person_last_interaction = person_object.fields.find(
|
||||
(field: any) => field.name === 'lastInteraction',
|
||||
);
|
||||
const person_interaction_status = person_object.fields.find(
|
||||
(field: any) => field.name === 'interactionStatus',
|
||||
);
|
||||
// If not, create them
|
||||
if (company_last_interaction === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_last_interaction(company_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created company last interaction field');
|
||||
}
|
||||
}
|
||||
if (company_interaction_status === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_interaction_status(company_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created company interaction status field');
|
||||
}
|
||||
}
|
||||
if (person_last_interaction === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_last_interaction(person_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created person last interaction field');
|
||||
}
|
||||
}
|
||||
if (person_interaction_status === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_interaction_status(person_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created person interaction status field');
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the timestamp of message
|
||||
const messageDate = properties.after.receivedAt;
|
||||
const interactionStatus = calculateStatus(messageDate);
|
||||
|
||||
// Get the details of person and related company
|
||||
const messageOptions = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/messages/${recordId}?depth=1`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
const messageDetails = await axios.request(messageOptions);
|
||||
const peopleIds: string[] = [];
|
||||
for (const participant of messageDetails.data.messages
|
||||
.messageParticipants) {
|
||||
peopleIds.push(participant.personId);
|
||||
}
|
||||
|
||||
const companiesIds = [];
|
||||
for (const id of peopleIds) {
|
||||
companiesIds.push(await fetchRelatedCompanyId(id));
|
||||
}
|
||||
// Update the field value depending on the timestamp
|
||||
for (const id of peopleIds) {
|
||||
await updateInteractionStatus("people", id, messageDate, interactionStatus);
|
||||
}
|
||||
|
||||
for (const id of companiesIds) {
|
||||
await updateInteractionStatus("companies", id, messageDate, interactionStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(error.message);
|
||||
return {};
|
||||
}
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const config: FunctionConfig = {
|
||||
universalIdentifier: '683966a0-b60a-424e-86b1-7448c9191bde',
|
||||
name: 'test',
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'f4f1e127-87f0-4dcf-99fe-8061adf5cbe6',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'message.created',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '4c17878f-b6b3-4d0a-8de6-967b1cb55002',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'message.updated',
|
||||
},
|
||||
],
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user