Compare commits

..
1 Commits
Author SHA1 Message Date
Paul Rastoinandprastoin 8aa04cc380 [Validate build and run] Early return if workspace migration has no actions (#16467)
# Introduction
Early returning in `validateBuildAndRun` as the runner even if passed an
empty workspace migration actions array be invalidating dependency
related flat entity maps.
When we will refactor the runner to consume the generic flat entity maps
tools such as
`addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow` we
will be able to remove the dependency cache invalidation
2025-12-10 15:29:42 +01:00
10322 changed files with 474066 additions and 595120 deletions
+2 -6
View File
@@ -56,9 +56,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 +70,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
+2 -13
View File
@@ -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
```
-22
View File
@@ -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",
}
]
-44
View File
@@ -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",
}
]
@@ -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 }}
+92 -92
View File
@@ -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
+3 -5
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
task: [lint, typecheck, test, build]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -38,11 +38,9 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build create-twenty-app
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:create-app
tasks: ${{ matrix.task }}
+1 -1
View File
@@ -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
+136
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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' || '' }}
+7 -7
View File
@@ -1,7 +1,9 @@
name: CI SDK
on:
merge_group:
push:
branches:
- main
pull_request:
@@ -25,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
task: [lint, typecheck, test, build]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -36,11 +38,9 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
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:sdk
tasks: ${{ matrix.task }}
@@ -76,7 +76,7 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Server / Append billing config to .env.test
working-directory: packages/twenty-server
run: |
+16 -14
View File
@@ -1,9 +1,11 @@
name: CI Server
on:
pull_request:
push:
branches:
- main
merge_group:
pull_request:
permissions:
contents: read
@@ -57,16 +59,16 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Restore server setup
id: restore-server-setup-cache
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Run lint & typecheck
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: lint,typecheck
@@ -138,7 +140,7 @@ jobs:
exit 1
fi
- name: Save server setup
uses: ./.github/actions/save-cache
uses: ./.github/workflows/actions/save-cache
with:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
@@ -151,13 +153,13 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Restore server setup
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Server / Run Tests
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: test
@@ -169,7 +171,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard: [1, 2, 3, 4, 5]
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -207,14 +209,14 @@ jobs:
ANALYTICS_ENABLED: true
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
CLICKHOUSE_PASSWORD: clickhousePassword
SHARD_COUNTER: 8
SHARD_COUNTER: 5
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Update .env.test for integrations tests
run: |
echo "" >> .env.test
@@ -224,7 +226,7 @@ jobs:
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
- name: Restore server setup
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Server / Build
@@ -241,7 +243,7 @@ jobs:
- name: Run ClickHouse seeds
run: npx nx clickhouse:seed twenty-server
- name: Server / Run Integration Tests
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: 'test:integration'
+5 -3
View File
@@ -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,9 @@
name: CI Docker Compose
name: 'Test Docker Compose'
permissions:
contents: read
on:
merge_group:
pull_request:
concurrency:
+3 -3
View File
@@ -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 -2
View File
@@ -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";'
+42 -34
View File
@@ -24,7 +24,7 @@ on:
pull_request:
paths:
- 'packages/twenty-docs/**'
- '.github/crowdin-docs.yml'
- 'crowdin.yml'
- '.github/workflows/docs-i18n-pull.yaml'
concurrency:
@@ -42,14 +42,31 @@ jobs:
token: ${{ github.token }}
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup i18n-docs branch
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Setup i18n branch
if: github.event_name != 'pull_request'
run: |
git fetch origin i18n-docs || true
git checkout -B i18n-docs origin/i18n-docs || git checkout -b i18n-docs
git fetch origin i18n || true
git checkout -B i18n origin/i18n || git checkout -b i18n
- name: Configure git
run: |
@@ -62,35 +79,26 @@ jobs:
git add .
git stash || true
# Install Crowdin CLI for downloading translations
- name: Install Crowdin CLI
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
run: npm install -g @crowdin/cli
# Pull docs translations from Crowdin one language at a time
# This avoids build timeout issues when processing all languages at once
- name: Pull translated docs from Crowdin
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
run: |
# Languages supported by Mintlify (see packages/twenty-docs/src/shared/supported-languages.ts)
LANGUAGES="fr ar cs de es it ja ko pt ro ru tr zh-CN"
for lang in $LANGUAGES; do
echo "=== Pulling translations for $lang ==="
crowdin download \
--config .github/crowdin-docs.yml \
--token "$CROWDIN_PERSONAL_TOKEN" \
--base-url "https://twenty.api.crowdin.com" \
--language "$lang" \
--skip-untranslated-strings=false \
--skip-untranslated-files=false \
--export-only-approved=false \
--verbose || echo "Warning: Failed to pull $lang, continuing with other languages..."
echo ""
done
echo "=== Download complete ==="
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
source: 'packages/twenty-docs/**/*.mdx'
translation: 'packages/twenty-docs/l/%two_letters_code%/**/%original_file_name%'
export_only_approved: false
localization_branch_name: i18n
base_url: 'https://twenty.api.crowdin.com'
skip_untranslated_files: true
push_translations: false
create_pull_request: false
skip_ref_checkout: true
dryrun_action: false
env:
GITHUB_TOKEN: ${{ github.token }}
CROWDIN_PROJECT_ID: '1'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Fix file permissions
@@ -134,13 +142,13 @@ jobs:
- name: Push changes
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
run: git push origin HEAD:i18n-docs
run: git push origin HEAD:i18n
- name: Create pull request
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
run: |
if git diff --name-only origin/main..HEAD | grep -q .; then
gh pr create -B main -H i18n-docs --title 'i18n - docs translations' --body 'Created by Github action' || true
gh pr create -B main -H i18n --title 'i18n - docs translations' --body 'Created by Github action' || true
else
echo "No file differences between branches, skipping PR creation"
fi
+26 -9
View File
@@ -7,12 +7,11 @@ on:
workflow_dispatch:
workflow_call:
push:
branches: ['main']
branches: ['main', 'docs-localized-navigation']
paths:
- 'packages/twenty-docs/**/*.mdx'
- '!packages/twenty-docs/l/**'
- 'packages/twenty-docs/navigation/navigation.template.json'
- '.github/crowdin-docs.yml'
- '!packages/twenty-docs/fr/**'
- 'crowdin.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -29,8 +28,28 @@ jobs:
token: ${{ github.token }}
ref: ${{ github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Install dependencies
uses: ./.github/actions/yarn-install
run: yarn install --frozen-lockfile
- name: Generate navigation template for Crowdin
run: yarn docs:generate-navigation-template
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Generate navigation template for Crowdin
run: yarn docs:generate-navigation-template
@@ -41,11 +60,9 @@ jobs:
upload_sources: true
upload_translations: false
download_translations: false
localization_branch_name: i18n-docs
localization_branch_name: i18n
base_url: 'https://twenty.api.crowdin.com'
config: '.github/crowdin-docs.yml'
env:
# Docs translations project
CROWDIN_PROJECT_ID: '2'
CROWDIN_PROJECT_ID: 1
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+4 -10
View File
@@ -46,7 +46,7 @@ jobs:
git checkout -B i18n origin/i18n || git checkout -b i18n
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
@@ -85,14 +85,12 @@ jobs:
push_sources: false
skip_untranslated_strings: false
skip_untranslated_files: false
push_translations: false
push_translations: true
create_pull_request: false
skip_ref_checkout: true
dryrun_action: false
config: '.github/crowdin-app.yml'
env:
GITHUB_TOKEN: ${{ github.token }}
# App translations project
CROWDIN_PROJECT_ID: '1'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
@@ -101,12 +99,6 @@ jobs:
- name: Fix file permissions
run: sudo chown -R runner:docker .
# Fix encoding issues (escaped Unicode like \u62db -> 招) and push fixes back to Crowdin
- name: Fix translation encoding and sync to Crowdin
run: npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Compile translations
id: compile_translations
# Because we have set English as a fallback locale, this condition does not work anymore
@@ -116,6 +108,8 @@ jobs:
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
git status
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add .
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile translations"
+5 -4
View File
@@ -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'
-118
View File
@@ -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 }}
-1
View File
@@ -48,4 +48,3 @@ dump.rdb
mcp.json
/.junie/
TRANSLATION_QA_REPORT.md
+4
View File
@@ -0,0 +1,4 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf"
}
View File
-1
View File
@@ -31,7 +31,6 @@
"editor.formatOnSave": true
},
"javascript.format.enable": false,
"javascript.preferences.importModuleSpecifier": "non-relative",
"typescript.format.enable": false,
"cSpell.enableFiletypes": [
"!javascript",
+4 -9
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
DOCKER_NETWORK=twenty_network
ensure-docker-network:
docker network inspect $(DOCKER_NETWORK) >/dev/null 2>&1 || docker network create $(DOCKER_NETWORK)
postgres-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_pg \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e ALLOW_NOSSL=true \
-v twenty_db_data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16
@echo "Waiting for PostgreSQL to be ready..."
@until docker exec twenty_pg psql -U postgres -d postgres \
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
sleep 1; \
done
docker exec twenty_pg psql -U postgres -d postgres \
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
redis-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
clickhouse-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest \
grafana-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_grafana \
-p 4000:3000 \
-e GF_SECURITY_ADMIN_USER=admin \
-e GF_SECURITY_ADMIN_PASSWORD=admin \
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
-v $(PWD)/packages/twenty-docker/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources \
grafana/grafana-oss:latest
opentelemetry-collector-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_otlp_collector \
-p 4317:4317 \
-p 4318:4318 \
-p 13133:13133 \
-v $(PWD)/packages/twenty-docker/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml \
otel/opentelemetry-collector-contrib:latest \
--config /etc/otel-collector-config.yaml
+63
View File
@@ -0,0 +1,63 @@
#
# Basic Crowdin CLI configuration
# See https://crowdin.github.io/crowdin-cli/configuration for more information
# See https://support.crowdin.com/developer/configuration-file/ for all available options
#
#
# Defines whether to preserve the original directory structure in the Crowdin project
# Recommended to set to true
#
"preserve_hierarchy": true
#
# Files configuration.
# See https://support.crowdin.com/developer/configuration-file/ for all available options
#
files: [
{
#
# Source files filter
# e.g. "/resources/en/*.json"
#
"source": "**/en.po",
#
# Translation files filter
# e.g. "/resources/%two_letters_code%/%original_file_name%"
#
"translation": "%original_path%/%locale%.po",
},
{
#
# MDX documentation files - user-guide
# Using md type to preserve JSX component structure
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
#
"source": "packages/twenty-docs/user-guide/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
},
{
#
# MDX documentation files - developers
# Using md type to preserve JSX component structure
#
"source": "packages/twenty-docs/developers/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
},
{
#
# MDX documentation files - twenty-ui
# Using md type to preserve JSX component structure
#
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
},
{
#
# Navigation labels template - translated into per-locale navigation.json
#
"source": "packages/twenty-docs/navigation/navigation.template.json",
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
}
]
+6 -2
View File
@@ -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',
+302
View File
@@ -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,
},
},
];
+5
View File
@@ -0,0 +1,5 @@
import { getJestProjects } from '@nx/jest';
export default {
projects: getJestProjects(),
};
-1
View File
@@ -1,4 +1,3 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = { ...nxPreset };
Executable
+5
View File
@@ -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 $@
+26 -96
View File
@@ -4,9 +4,7 @@
"libsDir": "packages"
},
"namedInputs": {
"default": [
"{projectRoot}/**/*"
],
"default": ["{projectRoot}/**/*"],
"excludeStories": [
"default",
"!{projectRoot}/.storybook/*",
@@ -34,26 +32,17 @@
"targetDefaults": {
"build": {
"cache": true,
"inputs": [
"^production",
"production"
],
"dependsOn": [
"^build"
]
"inputs": ["^production", "production"],
"dependsOn": ["^build"]
},
"start": {
"cache": false,
"dependsOn": [
"^build"
]
"dependsOn": ["^build"]
},
"lint": {
"executor": "@nx/eslint:lint",
"cache": true,
"outputs": [
"{options.outputFile}"
],
"outputs": ["{options.outputFile}"],
"options": {
"eslintConfig": "{projectRoot}/eslint.config.mjs",
"cache": true,
@@ -67,22 +56,7 @@
"fix": true
}
},
"dependsOn": [
"^build"
]
},
"lint:diff-with-main": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs \"$@\"; fi' _",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs --fix \"$@\"; fi' _"
}
}
"dependsOn": ["^build"]
},
"fmt": {
"executor": "nx:run-commands",
@@ -103,9 +77,7 @@
"write": true
}
},
"dependsOn": [
"^build"
]
"dependsOn": ["^build"]
},
"typecheck": {
"executor": "nx:run-commands",
@@ -119,30 +91,22 @@
"watch": true
}
},
"dependsOn": [
"^build"
]
"dependsOn": ["^build"]
},
"test": {
"executor": "@nx/jest:jest",
"cache": true,
"dependsOn": [
"^build"
],
"dependsOn": ["^build"],
"inputs": [
"^default",
"excludeStories",
"{workspaceRoot}/jest.preset.js"
],
"outputs": [
"{projectRoot}/coverage"
],
"outputs": ["{projectRoot}/coverage"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs",
"coverage": true,
"coverageReporters": [
"text-summary"
],
"coverageReporters": ["text-summary"],
"cacheDirectory": "../../.cache/jest/{projectRoot}"
},
"configurations": {
@@ -151,10 +115,7 @@
"maxWorkers": 3
},
"coverage": {
"coverageReporters": [
"lcov",
"text"
]
"coverageReporters": ["lcov", "text"]
},
"watch": {
"watch": true
@@ -163,36 +124,25 @@
},
"test:e2e": {
"cache": true,
"dependsOn": [
"^build"
]
"dependsOn": ["^build"]
},
"storybook:build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"^default",
"excludeTests"
],
"outputs": [
"{projectRoot}/{options.output-dir}"
],
"inputs": ["^default", "excludeTests"],
"outputs": ["{projectRoot}/{options.output-dir}"],
"options": {
"cwd": "{projectRoot}",
"command": "NODE_OPTIONS='--max-old-space-size=10240' VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
"output-dir": "storybook-static",
"config-dir": ".storybook"
},
"dependsOn": [
"^build"
]
"dependsOn": ["^build"]
},
"storybook:serve:dev": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"dependsOn": ["^build"],
"options": {
"cwd": "{projectRoot}",
"command": "storybook dev",
@@ -201,9 +151,7 @@
},
"storybook:serve:static": {
"executor": "nx:run-commands",
"dependsOn": [
"storybook:build"
],
"dependsOn": ["storybook:build"],
"options": {
"cwd": "{projectRoot}",
"command": "npx http-server {args.staticDir} -a={args.host} --port={args.port} --silent={args.silent}",
@@ -216,13 +164,8 @@
"storybook:test": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"^default",
"excludeTests"
],
"outputs": [
"{projectRoot}/coverage/storybook"
],
"inputs": ["^default", "excludeTests"],
"outputs": ["{projectRoot}/coverage/storybook"],
"options": {
"cwd": "{projectRoot}",
"commands": [
@@ -238,10 +181,7 @@
},
"storybook:test:no-coverage": {
"executor": "nx:run-commands",
"inputs": [
"^default",
"excludeTests"
],
"inputs": ["^default", "excludeTests"],
"options": {
"cwd": "{projectRoot}",
"commands": [
@@ -331,20 +271,12 @@
},
"@nx/vite:test": {
"cache": true,
"inputs": [
"default",
"^default"
]
"inputs": ["default", "^default"]
},
"@nx/vite:build": {
"cache": true,
"dependsOn": [
"^build"
],
"inputs": [
"default",
"^default"
]
"dependsOn": ["^build"],
"inputs": ["default", "^default"]
}
},
"installation": {
@@ -376,9 +308,7 @@
"tasksRunnerOptions": {
"default": {
"options": {
"cacheableOperations": [
"storybook:build"
]
"cacheableOperations": ["storybook:build"]
}
}
},
+22 -19
View File
@@ -2,6 +2,7 @@
"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",
@@ -52,7 +53,7 @@
"rxjs": "^7.2.0",
"semver": "^7.5.4",
"slash": "^5.1.0",
"temporal-polyfill": "^0.3.0",
"storybook-addon-mock-date": "^0.6.0",
"ts-key-enum": "^2.0.12",
"tslib": "^2.8.1",
"type-fest": "4.10.1",
@@ -66,7 +67,7 @@
"@babel/core": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.24.6",
"@chromatic-com/storybook": "^4.1.3",
"@chromatic-com/storybook": "^3",
"@graphql-codegen/cli": "^3.3.1",
"@graphql-codegen/client-preset": "^4.1.0",
"@graphql-codegen/typescript": "^3.0.4",
@@ -81,20 +82,26 @@
"@nx/vite": "22.0.3",
"@nx/web": "22.0.3",
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-links": "^10.1.11",
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.1.11",
"@storybook/test-runner": "^0.24.2",
"@storybook/addon-actions": "8.6.14",
"@storybook/addon-coverage": "^1.0.0",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-interactions": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/blocks": "8.6.14",
"@storybook/core-server": "8.6.14",
"@storybook/icons": "^1.2.9",
"@storybook/preview-api": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/react-vite": "8.6.14",
"@storybook/test": "8.6.14",
"@storybook/test-runner": "^0.23.0",
"@storybook/types": "8.6.14",
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.8.0",
"@swc/cli": "^0.3.12",
"@swc/core": "1.13.3",
"@swc/helpers": "~0.5.2",
"@swc/jest": "^0.2.39",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/addressparser": "^1.0.3",
@@ -102,6 +109,7 @@
"@types/bytes": "^3.1.1",
"@types/chrome": "^0.0.267",
"@types/deep-equal": "^1.0.1",
"@types/express": "^4.17.13",
"@types/fs-extra": "^11.0.4",
"@types/graphql-fields": "^1.3.6",
"@types/inquirer": "^9.0.9",
@@ -154,7 +162,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^10.1.11",
"eslint-plugin-storybook": "^0.9.0",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"http-server": "^14.1.1",
@@ -170,9 +178,9 @@
"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",
@@ -222,10 +230,5 @@
"packages/create-twenty-app",
"tools/eslint-rules"
]
},
"prettier": {
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf"
}
}
-1
View File
@@ -23,7 +23,6 @@ Create Twenty App is the official scaffolding CLI for building apps on top of [T
- 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.3.1",
"version": "0.2.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -1,29 +1,137 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
export default [
// Base JS recommended rules
// Base JS rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
// Global ignores
{
files: ['**/*.ts', '**/*.tsx'],
ignores: ['**/node_modules/**', '**/dist/**', '**/coverage/**'],
},
// Base config for TS/JS files
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: {
prettier: prettierPlugin,
import: importPlugin,
'prefer-arrow': preferArrowPlugin,
'unused-imports': unusedImportsPlugin,
},
rules: {
// General rules (aligned with main project)
'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',
// 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: '^_',
},
],
// Prettier (formatting as lint errors if you want)
'prettier/prettier': 'error',
},
},
// TypeScript-specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
// Turn off base rule and use TS-aware versions
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'error',
'@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-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
// Test files (Jest)
{
files: ['**/*.spec.@(ts|tsx|js|jsx)', '**/*.test.@(ts|tsx|js|jsx)'],
languageOptions: {
globals: {
jest: true,
describe: true,
it: true,
expect: true,
beforeEach: true,
afterEach: true,
beforeAll: true,
afterAll: true,
},
},
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
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
];
@@ -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);
});
});
@@ -2,8 +2,6 @@ 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,
@@ -23,19 +21,10 @@ export const copyBaseApplicationProject = async ({
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,
appDirectory,
});
};
@@ -87,34 +76,6 @@ yarn-error.log*
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,
@@ -124,15 +85,15 @@ const createApplicationConfig = async ({
description?: string;
appDirectory: string;
}) => {
const content = `import { defineApp } from 'twenty-sdk';
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role';
const content = `import { type ApplicationConfig } from 'twenty-sdk';
export default defineApp({
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
});
};
export default config;
`;
await fs.writeFile(join(appDirectory, 'application.config.ts'), content);
@@ -164,17 +125,13 @@ const createPackageJson = async ({
uninstall: 'twenty app uninstall',
help: 'twenty help',
auth: 'twenty auth login',
lint: 'eslint',
'lint-fix': 'eslint --fix',
},
dependencies: {
'twenty-sdk': '0.3.1',
'twenty-sdk': '0.2.0',
},
devDependencies: {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
typescript: '^5.9.3',
},
};
@@ -3,21 +3,19 @@
Updates Last interaction and Interaction status fields based on last email date
## Requirements
- an `apiKey` - go to Settings > API & Webhooks to generate one
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/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
twenty auth login
cd last_email_interaction
twenty app 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
- Fetches all users and companies connected to them and updates their Last interaction and Interaction status fields
@@ -1,11 +1,10 @@
import { type ApplicationConfig } from 'twenty-sdk';
import { type ApplicationConfig } from 'twenty-sdk/application';
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',
@@ -10,19 +10,9 @@
"packageManager": "yarn@4.9.2",
"dependencies": {
"axios": "^1.12.2",
"twenty-sdk": "0.2.4"
"twenty-sdk": "0.0.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"
}
}
@@ -0,0 +1,277 @@
import axios from 'axios';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
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 interactionData = (date: string, status: string) => {
return {
lastInteraction: date,
interactionStatus: status,
};
};
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: ServerlessFunctionConfig = {
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',
},
],
};
@@ -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',
},
],
};
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import typescriptParser from '@typescript-eslint/parser';
import path from 'path';
import { fileURLToPath } from 'url';
import reactConfig from '../../../../../tools/eslint-rules/eslint.config.react.mjs';
import reactConfig from '../../eslint.config.react.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -12,28 +12,19 @@ function App() {
useEffect(() => {
browser.tabs.query({ active: true, currentWindow: true }, function(tabs) {
const currentTab = tabs[0];
if(currentTab.url?.includes('https://www.linkedin.com/in')) {
sendMessage('getPersonviaRelay').then(data => {
setValue({...data, type: 'person' })
})
}
if (!currentTab.url) {
return;
}
let url = new URL(currentTab.url);
const isLinkedinHost =
url.protocol === 'https:' && url.hostname === 'www.linkedin.com';
if (isLinkedinHost && url.pathname.startsWith('/in/')) {
sendMessage('getPersonviaRelay').then((data) => {
setValue({ ...data, type: 'person' });
});
}
if (isLinkedinHost && url.pathname.startsWith('/company/')) {
sendMessage('getCompanyviaRelay').then((data) => {
setValue({ ...data, type: 'company' });
});
if(currentTab.url?.includes('https://www.linkedin.com/company')) {
sendMessage('getCompanyviaRelay').then(data => {
setValue({...data, type: 'company'})
})
}
});
}, []);
const isPersonValue = (val: Value): val is PersonValue => {
@@ -47,29 +38,25 @@ function App() {
return (
<StyledMain>
<h1>{JSON.stringify(value)}</h1>
{isPersonValue(value) && (
<button
onClick={async () => {
await sendMessage('createPerson', {
firstName: value.firstName,
lastName: value.lastName,
});
}}
>
save person to twenty
</button>
)}
{isCompanyValue(value) && (
<button
onClick={async () => {
await sendMessage('createCompany', {
name: value.companyName,
});
}}
>
save company to twenty
</button>
)}
{
isPersonValue(value) &&
<button onClick={async () => {
await sendMessage('createPerson', {
firstName: value.firstName,
lastName: value.lastName,
});
}}>save person to twenty</button>
}
{
isCompanyValue(value) &&
<button onClick={async () => {
await sendMessage('createCompany', {
name: value.companyName
});
}}>save company to twenty</button>
}
</StyledMain>
);
}
@@ -3,15 +3,16 @@
Synchronizing contacts between Twenty and Mailchimp
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
- Mailchimp API key - Mailchimp > avatar in top right corner > Profile > Extras > API keys
## Setup
1. Add app to your workspace
```bash
cd packages/twenty-apps/community/mailchimp-synchronizer
yarn auth
yarn generate
yarn sync
twenty auth login
cd mailchimp-synchronizer
twenty app sync
```
2. Go to Settings > Integrations > Mailchimp synchronizer > Settings and add required variables
@@ -1,4 +1,4 @@
import { type ApplicationConfig } from 'twenty-sdk';
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '1eadac4e-db9f-4cce-b20b-de75f41e34dc',
@@ -6,6 +6,16 @@ const config: ApplicationConfig = {
description: 'Synchronizes Twenty contacts in Mailchimp',
icon: "IconMailFast",
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '0af17af3-66b8-40cf-b6e2-6a29a1da5464',
isSecret: true,
description: 'Required to send requests to Twenty',
},
TWENTY_API_URL: {
universalIdentifier: '12949c1c-aed7-4a9f-bd06-9fd15f0bfa63',
isSecret: false,
description: 'Optional, defaults to cloud API URL',
},
MAILCHIMP_API_KEY: {
universalIdentifier: 'f10d4e8a-8055-4eb2-b9ad-efd69d43b1f0',
isSecret: true,
@@ -10,19 +10,9 @@
"packageManager": "yarn@4.9.2",
"dependencies": {
"axios": "^1.13.2",
"twenty-sdk": "0.2.4"
"twenty-sdk": "0.0.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"
}
}
@@ -0,0 +1,454 @@
import axios from 'axios';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
const TWENTY_API_URL: string =
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
? `${process.env.TWENTY_API_URL}/rest`
: 'https://api.twenty.com/rest';
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
const MAILCHIMP_API_URL: string =
process.env.MAILCHIMP_SERVER_PREFIX !== '' &&
process.env.MAILCHIMP_SERVER_PREFIX !== undefined
? `https://${process.env.MAILCHIMP_SERVER_PREFIX}.api.mailchimp.com/3.0/`
: '';
const MAILCHIMP_API_KEY: string = process.env.MAILCHIMP_API_KEY ?? '';
const MAILCHIMP_AUDIENCE_ID: string = process.env.MAILCHIMP_AUDIENCE_ID ?? '';
const IS_EMAIL_CONSTRAINT: boolean = process.env.IS_EMAIL_CONSTRAINT === 'true';
const IS_COMPANY_CONSTRAINT: boolean =
process.env.COMPANY_CONSTRAINT === 'true';
const IS_PHONE_CONSTRAINT: boolean = process.env.IS_PHONE_CONSTRAINT === 'true';
const IS_ADDRESS_CONSTRAINT: boolean =
process.env.IS_ADDRESS_CONSTRAINT === 'true';
const UPDATE_PERSON: boolean = process.env.UPDATE_PERSON === 'true';
type mailchimpAddress = {
street1: string;
street2: string;
city: string;
state: string;
zipCode: string;
country: string;
};
type mailchimpRecord = {
id?: string;
email_channel?: {
email: string;
marketing_consent?: {
status: string;
};
};
sms_channel?: {
sms_phone: string;
marketing_consent?: {
status: string;
};
};
mergeFields: {
FNAME: string;
LNAME: string;
ADDRESS: string | mailchimpAddress;
COMPANY: string;
PHONE: string;
};
};
type twentyAddress = {
addressStreet1: string;
addressStreet2: string;
addressCity: string;
addressState: string;
addressPostCode: string;
addressCountry: string;
};
type twentyCompany = {
name: string;
address: twentyAddress;
};
type twentyPerson = {
name: {
firstName: string;
lastName: string;
};
emails: {
primaryEmail: string;
};
phones: {
primaryPhoneNumber: string;
primaryPhoneCallingCode: string;
};
companyId: string | null;
};
const fetchCompanyData = async (companyId: string): Promise<twentyCompany> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/company/${companyId}`,
};
try {
const response = await axios.request(options);
return response.status === 200
? ({
name: response.data.name as string,
address: response.data.address as twentyAddress,
} as twentyCompany)
: ({} as twentyCompany);
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
throw error;
}
};
const checkAddress = (address: twentyAddress): mailchimpAddress => {
if (
address.addressStreet1 !== '' &&
address.addressCity !== '' &&
address.addressPostCode !== '' &&
address.addressState !== ''
) {
return {
street1: address.addressStreet1,
street2: address.addressStreet2,
city: address.addressCity,
state: address.addressState,
zipCode: address.addressPostCode,
country: address.addressCountry,
} as mailchimpAddress;
}
throw new Error('Invalid address');
};
const checkAudiencePermissions = async (
audienceId: string,
): Promise<string[] | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
},
url: `${MAILCHIMP_API_URL}/audiences/${audienceId}`,
};
try {
const temp = await axios.request(options);
return temp.data.enabled_channels;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const prepareData = (
firstName: string,
lastName: string,
email: string,
phoneNumber: string,
phoneCallingCode: string,
companyName: string,
address: mailchimpAddress | string,
): mailchimpRecord => {
let data = {
mergeFields: {},
} as mailchimpRecord;
data.mergeFields.FNAME = firstName;
data.mergeFields.LNAME = lastName;
if (IS_EMAIL_CONSTRAINT) {
data.email_channel = {
email: email,
marketing_consent: {
status: 'unknown',
},
};
}
if (IS_ADDRESS_CONSTRAINT) {
data.mergeFields.ADDRESS = address;
}
if (IS_PHONE_CONSTRAINT) {
const mergedPhoneNumber: string = phoneCallingCode.startsWith('+')
? phoneCallingCode.concat(phoneNumber)
: '+'.concat(phoneCallingCode, phoneNumber);
data['sms_channel'] = {
sms_phone: mergedPhoneNumber,
marketing_consent: {
status: 'unknown',
},
};
data['mergeFields']['PHONE'] = mergedPhoneNumber;
}
if (IS_COMPANY_CONSTRAINT) {
data['mergeFields']['COMPANY'] = companyName;
}
return data;
};
const addTwentyPersonToMailchimp = async (
convertedRecord: mailchimpRecord,
): Promise<boolean | undefined> => {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
},
url: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts`,
data: convertedRecord,
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfTwentyPersonExistsInMailchimp = async (
email?: string,
phoneNumber?: string,
cursor?: string,
) => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
},
url: cursor
? `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts?count%3D1000%26cursor%3D${cursor}`
: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts?count%3D1000`,
};
try {
const response = await axios.request(options);
const doesPersonExist: mailchimpRecord | undefined =
(response.data.contacts.find(
(contact: any) => contact.email_channel.email === email,
) as mailchimpRecord) ||
(response.data.contacts.find(
(contact: any) => contact.sms_channel.sms_phone === phoneNumber,
) as mailchimpRecord);
if (doesPersonExist !== undefined) {
return doesPersonExist;
}
if (response.data.next_cursor === undefined) {
return undefined;
} else {
await checkIfTwentyPersonExistsInMailchimp(
email,
phoneNumber,
response.data.next_cursor,
);
}
return undefined;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const compareTwoRecords = (
object1: mailchimpRecord,
object2: mailchimpRecord,
) => {
return (
object1.email_channel?.email === object2.email_channel?.email &&
object1.sms_channel?.sms_phone === object2.sms_channel?.sms_phone &&
object1.mergeFields.FNAME === object2.mergeFields.FNAME &&
object1.mergeFields.LNAME === object2.mergeFields.LNAME &&
object1.mergeFields.ADDRESS === object2.mergeFields.ADDRESS &&
object1.mergeFields.COMPANY === object2.mergeFields.COMPANY
);
};
const updateTwentyPersonInMailchimp = async (
mailchimpRecordId: string,
twentyConvertedRecord: mailchimpRecord,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts/${mailchimpRecordId}`,
data: twentyConvertedRecord,
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
export const main = async (params: {
properties: Record<string, any>;
recordId: string;
userId: string;
}): Promise<object | undefined> => {
if (!IS_EMAIL_CONSTRAINT && !IS_PHONE_CONSTRAINT) {
console.warn(
'Function exited as there are no constraints to email nor phone number',
);
return {};
}
if (
MAILCHIMP_API_URL === '' ||
MAILCHIMP_API_KEY === '' ||
MAILCHIMP_AUDIENCE_ID === ''
) {
console.warn('Missing Mailchimp required parameters');
return {};
}
if (IS_COMPANY_CONSTRAINT && TWENTY_API_KEY === '') {
console.warn('Missing Twenty related parameters');
return {};
}
try {
const { properties } = params;
const twentyRecord: twentyPerson = properties.after as twentyPerson;
if (
twentyRecord.name.firstName === '' ||
twentyRecord.name.lastName === ''
) {
throw new Error('First or last name is empty');
}
const audiencePermissions: string[] | undefined =
await checkAudiencePermissions(MAILCHIMP_AUDIENCE_ID);
if (
IS_EMAIL_CONSTRAINT &&
audiencePermissions?.includes('Email') &&
twentyRecord.emails.primaryEmail === ''
) {
throw new Error('Email is empty');
}
if (
IS_PHONE_CONSTRAINT &&
audiencePermissions?.includes('SMS') &&
(twentyRecord.phones.primaryPhoneNumber === '' ||
twentyRecord.phones.primaryPhoneCallingCode === '')
) {
throw new Error('Phone number is empty');
}
let companyName: string = '';
let address: mailchimpAddress | string = '';
if (IS_COMPANY_CONSTRAINT) {
if (twentyRecord.companyId === null) {
throw new Error('Missing relation to company record');
}
const company: twentyCompany = await fetchCompanyData(
twentyRecord.companyId,
);
companyName = company.name ?? ''; // either "" or name
address = IS_ADDRESS_CONSTRAINT ? checkAddress(company.address) : '';
}
const twentyPersonToMailchimpRecord: mailchimpRecord = prepareData(
twentyRecord.name.firstName,
twentyRecord.name.lastName,
twentyRecord.emails.primaryEmail,
twentyRecord.phones.primaryPhoneNumber,
twentyRecord.phones.primaryPhoneCallingCode,
companyName,
address,
);
console.log(twentyPersonToMailchimpRecord);
const isTwentyPersonInMailchimp: mailchimpRecord | undefined =
await checkIfTwentyPersonExistsInMailchimp(
twentyRecord.emails.primaryEmail,
twentyPersonToMailchimpRecord.sms_channel?.sms_phone,
);
if (isTwentyPersonInMailchimp !== undefined) {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} found in Mailchimp`,
);
if (UPDATE_PERSON) {
if (
!compareTwoRecords(
isTwentyPersonInMailchimp,
twentyPersonToMailchimpRecord,
) &&
isTwentyPersonInMailchimp.id
) {
const isTwentyPersonUpdatedInMailchimp: boolean | undefined =
await updateTwentyPersonInMailchimp(
isTwentyPersonInMailchimp.id,
twentyPersonToMailchimpRecord,
);
if (!isTwentyPersonUpdatedInMailchimp) {
throw new Error(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} update in Mailchimp failed`,
);
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} update in Mailchimp succeeded`,
);
}
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} wasn't updated in Mailchimp because they're the same`,
);
}
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} wasn't updated in Mailchimp because UPDATE_PERSON is set to false`,
);
}
} else {
console.log(
`${twentyRecord.name.firstName} ${twentyRecord.name.lastName} doesn't exist in Mailchimp, adding`,
);
const isTwentyPersonAddedToMailchimp: boolean | undefined =
await addTwentyPersonToMailchimp(twentyPersonToMailchimpRecord);
if (!isTwentyPersonAddedToMailchimp) {
throw new Error(
`Adding ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} person to Mailchimp failed`,
);
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} has been successfully added`,
);
}
}
return {};
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
return {};
}
console.error(error);
return {};
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '83319670-775b-4862-b133-5c353e594151',
name: 'mailchimp-synchronizer',
triggers: [
{
universalIdentifier: 'e627ff6f-0a0c-48b2-bdbb-31967489ec96',
type: 'databaseEvent',
eventName: 'person.created',
},
{
universalIdentifier: '657ece26-4478-4408-a257-4e9e16cce279',
type: 'databaseEvent',
eventName: 'person.updated',
},
],
};
@@ -1,447 +0,0 @@
import axios from 'axios';
import {
type DatabaseEventPayload,
type FunctionConfig,
type ObjectRecordCreateEvent,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import Twenty, { type Person } from '../generated';
const MAILCHIMP_API_URL: string =
process.env.MAILCHIMP_SERVER_PREFIX !== '' &&
process.env.MAILCHIMP_SERVER_PREFIX !== undefined
? `https://${process.env.MAILCHIMP_SERVER_PREFIX}.api.mailchimp.com/3.0/`
: '';
const MAILCHIMP_API_KEY: string = process.env.MAILCHIMP_API_KEY ?? '';
const MAILCHIMP_AUDIENCE_ID: string = process.env.MAILCHIMP_AUDIENCE_ID ?? '';
const IS_EMAIL_CONSTRAINT: boolean = process.env.IS_EMAIL_CONSTRAINT === 'true';
const IS_COMPANY_CONSTRAINT: boolean =
process.env.COMPANY_CONSTRAINT === 'true';
const IS_PHONE_CONSTRAINT: boolean = process.env.IS_PHONE_CONSTRAINT === 'true';
const IS_ADDRESS_CONSTRAINT: boolean =
process.env.IS_ADDRESS_CONSTRAINT === 'true';
const UPDATE_PERSON: boolean = process.env.UPDATE_PERSON === 'true';
type mailchimpAddress = {
street1: string;
street2: string;
city: string;
state: string;
zipCode: string;
country: string;
};
type mailchimpRecord = {
id?: string;
email_channel?: {
email: string;
marketing_consent?: {
status: string;
};
};
sms_channel?: {
sms_phone: string;
marketing_consent?: {
status: string;
};
};
mergeFields: {
FNAME: string;
LNAME: string;
ADDRESS: string | mailchimpAddress;
COMPANY: string;
PHONE: string;
};
};
type twentyAddress = {
addressStreet1: string;
addressStreet2: string;
addressCity: string;
addressState: string;
addressPostCode: string;
addressCountry: string;
};
type twentyCompany = {
name: string;
address: twentyAddress;
};
type twentyPerson = {
name: {
firstName: string;
lastName: string;
};
emails: {
primaryEmail: string;
};
phones: {
primaryPhoneNumber: string;
primaryPhoneCallingCode: string;
};
companyId: string | null;
};
type DatabaseEvent =
| DatabaseEventPayload<ObjectRecordCreateEvent<Person>>
| DatabaseEventPayload<ObjectRecordUpdateEvent<Person>>;
const fetchCompanyData = async (companyId: string): Promise<twentyCompany> => {
const { company } = await new Twenty().query({
company: {
__args: { filter: { id: { eq: companyId } } },
name: true,
address: {
addressStreet1: true,
addressStreet2: true,
addressState: true,
addressPostcode: true,
addressCity: true,
addressCountry: true,
},
},
});
return company as unknown as twentyCompany;
};
const checkAddress = (address: twentyAddress): mailchimpAddress => {
if (
address.addressStreet1 !== '' &&
address.addressCity !== '' &&
address.addressPostCode !== '' &&
address.addressState !== ''
) {
return {
street1: address.addressStreet1,
street2: address.addressStreet2,
city: address.addressCity,
state: address.addressState,
zipCode: address.addressPostCode,
country: address.addressCountry,
} as mailchimpAddress;
}
throw new Error('Invalid address');
};
const checkAudiencePermissions = async (
audienceId: string,
): Promise<string[] | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
},
url: `${MAILCHIMP_API_URL}/audiences/${audienceId}`,
};
try {
const temp = await axios.request(options);
return temp.data.enabled_channels;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const prepareData = (
firstName: string,
lastName: string,
email: string,
phoneNumber: string,
phoneCallingCode: string,
companyName: string,
address: mailchimpAddress | string,
): mailchimpRecord => {
let data = {
mergeFields: {},
} as mailchimpRecord;
data.mergeFields.FNAME = firstName;
data.mergeFields.LNAME = lastName;
if (IS_EMAIL_CONSTRAINT) {
data.email_channel = {
email: email,
marketing_consent: {
status: 'unknown',
},
};
}
if (IS_ADDRESS_CONSTRAINT) {
data.mergeFields.ADDRESS = address;
}
if (IS_PHONE_CONSTRAINT) {
const mergedPhoneNumber: string = phoneCallingCode.startsWith('+')
? phoneCallingCode.concat(phoneNumber)
: '+'.concat(phoneCallingCode, phoneNumber);
data['sms_channel'] = {
sms_phone: mergedPhoneNumber,
marketing_consent: {
status: 'unknown',
},
};
data['mergeFields']['PHONE'] = mergedPhoneNumber;
}
if (IS_COMPANY_CONSTRAINT) {
data['mergeFields']['COMPANY'] = companyName;
}
return data;
};
const addTwentyPersonToMailchimp = async (
convertedRecord: mailchimpRecord,
): Promise<boolean | undefined> => {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
},
url: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts`,
data: convertedRecord,
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfTwentyPersonExistsInMailchimp = async (
email?: string,
phoneNumber?: string,
cursor?: string,
) => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
},
url: cursor
? `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts?count%3D1000%26cursor%3D${cursor}`
: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts?count%3D1000`,
};
try {
const response = await axios.request(options);
const doesPersonExist: mailchimpRecord | undefined =
(response.data.contacts.find(
(contact: any) => contact.email_channel.email === email,
) as mailchimpRecord) ||
(response.data.contacts.find(
(contact: any) => contact.sms_channel.sms_phone === phoneNumber,
) as mailchimpRecord);
if (doesPersonExist !== undefined) {
return doesPersonExist;
}
if (response.data.next_cursor === undefined) {
return undefined;
} else {
await checkIfTwentyPersonExistsInMailchimp(
email,
phoneNumber,
response.data.next_cursor,
);
}
return undefined;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const compareTwoRecords = (
object1: mailchimpRecord,
object2: mailchimpRecord,
) => {
return (
object1.email_channel?.email === object2.email_channel?.email &&
object1.sms_channel?.sms_phone === object2.sms_channel?.sms_phone &&
object1.mergeFields.FNAME === object2.mergeFields.FNAME &&
object1.mergeFields.LNAME === object2.mergeFields.LNAME &&
object1.mergeFields.ADDRESS === object2.mergeFields.ADDRESS &&
object1.mergeFields.COMPANY === object2.mergeFields.COMPANY
);
};
const updateTwentyPersonInMailchimp = async (
mailchimpRecordId: string,
twentyConvertedRecord: mailchimpRecord,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${MAILCHIMP_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${MAILCHIMP_API_URL}/audiences/${MAILCHIMP_AUDIENCE_ID}/contacts/${mailchimpRecordId}`,
data: twentyConvertedRecord,
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
export const main = async (
params: DatabaseEvent,
): Promise<object | undefined> => {
if (!IS_EMAIL_CONSTRAINT && !IS_PHONE_CONSTRAINT) {
console.warn(
'Function exited as there are no constraints to email nor phone number',
);
return {};
}
if (
MAILCHIMP_API_URL === '' ||
MAILCHIMP_API_KEY === '' ||
MAILCHIMP_AUDIENCE_ID === ''
) {
console.warn('Missing Mailchimp required parameters');
return {};
}
try {
const { properties } = params;
const twentyRecord: twentyPerson = properties.after as twentyPerson;
if (
twentyRecord.name.firstName === '' ||
twentyRecord.name.lastName === ''
) {
throw new Error('First or last name is empty');
}
const audiencePermissions: string[] | undefined =
await checkAudiencePermissions(MAILCHIMP_AUDIENCE_ID);
if (
IS_EMAIL_CONSTRAINT &&
audiencePermissions?.includes('Email') &&
twentyRecord.emails.primaryEmail === ''
) {
throw new Error('Email is empty');
}
if (
IS_PHONE_CONSTRAINT &&
audiencePermissions?.includes('SMS') &&
(twentyRecord.phones.primaryPhoneNumber === '' ||
twentyRecord.phones.primaryPhoneCallingCode === '')
) {
throw new Error('Phone number is empty');
}
let companyName: string = '';
let address: mailchimpAddress | string = '';
if (IS_COMPANY_CONSTRAINT) {
if (twentyRecord.companyId === null) {
throw new Error('Missing relation to company record');
}
const company: twentyCompany = await fetchCompanyData(
twentyRecord.companyId,
);
companyName = company.name ?? ''; // either "" or name
address = IS_ADDRESS_CONSTRAINT ? checkAddress(company.address) : '';
}
const twentyPersonToMailchimpRecord: mailchimpRecord = prepareData(
twentyRecord.name.firstName,
twentyRecord.name.lastName,
twentyRecord.emails.primaryEmail,
twentyRecord.phones.primaryPhoneNumber,
twentyRecord.phones.primaryPhoneCallingCode,
companyName,
address,
);
console.log(twentyPersonToMailchimpRecord);
const isTwentyPersonInMailchimp: mailchimpRecord | undefined =
await checkIfTwentyPersonExistsInMailchimp(
twentyRecord.emails.primaryEmail,
twentyPersonToMailchimpRecord.sms_channel?.sms_phone,
);
if (isTwentyPersonInMailchimp !== undefined) {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} found in Mailchimp`,
);
if (UPDATE_PERSON) {
if (
!compareTwoRecords(
isTwentyPersonInMailchimp,
twentyPersonToMailchimpRecord,
) &&
isTwentyPersonInMailchimp.id
) {
const isTwentyPersonUpdatedInMailchimp: boolean | undefined =
await updateTwentyPersonInMailchimp(
isTwentyPersonInMailchimp.id,
twentyPersonToMailchimpRecord,
);
if (!isTwentyPersonUpdatedInMailchimp) {
throw new Error(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} update in Mailchimp failed`,
);
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} update in Mailchimp succeeded`,
);
}
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} wasn't updated in Mailchimp because they're the same`,
);
}
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} wasn't updated in Mailchimp because UPDATE_PERSON is set to false`,
);
}
} else {
console.log(
`${twentyRecord.name.firstName} ${twentyRecord.name.lastName} doesn't exist in Mailchimp, adding`,
);
const isTwentyPersonAddedToMailchimp: boolean | undefined =
await addTwentyPersonToMailchimp(twentyPersonToMailchimpRecord);
if (!isTwentyPersonAddedToMailchimp) {
throw new Error(
`Adding ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} person to Mailchimp failed`,
);
} else {
console.log(
`Person ${twentyRecord.name.firstName} ${twentyRecord.name.lastName} has been successfully added`,
);
}
}
return {};
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response);
return {};
}
console.error(error);
return {};
}
};
export const config: FunctionConfig = {
universalIdentifier: '83319670-775b-4862-b133-5c353e594151',
name: 'mailchimp-synchronizer',
triggers: [
{
universalIdentifier: 'e627ff6f-0a0c-48b2-bdbb-31967489ec96',
type: 'databaseEvent',
eventName: 'person.created',
},
{
universalIdentifier: '657ece26-4478-4408-a257-4e9e16cce279',
type: 'databaseEvent',
eventName: 'person.updated',
},
],
};
File diff suppressed because it is too large Load Diff
@@ -3,15 +3,16 @@
Synchronizes customers from Stripe to Twenty
## Requirements
- an `apiKey` - go to Settings > API & Webhooks to generate one
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
- Stripe secret API key - available in Stripe workbench
## Setup
1. Synchronize app
```bash
cd packages/twenty-apps/community/stripe-synchronizer
yarn auth
yarn sync
twenty auth login
cd stripe-synchronizer
twenty app sync
```
2. Go to Stripe > Workbench > Webhooks and add webhook:
- events: customer.subscription.created and customer.subscription.updated
@@ -33,5 +34,4 @@ yarn sync
## Todo
- add validation of signature key from Stripe to ensure that incoming request is valid
(possible once request headers are exposed to serverless functions)
- update app so it'll use provided Twenty generated object with native types from workspace once extending objects is possible
(possible once request headers are exposed to serverless functions)
@@ -1,4 +1,4 @@
import { type ApplicationConfig } from 'twenty-sdk';
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '0ed2bcb8-64ab-4ca1-b875-eeabf41b5f95',
@@ -10,19 +10,9 @@
"packageManager": "yarn@4.9.2",
"dependencies": {
"axios": "^1.13.2",
"twenty-sdk": "0.2.4"
"twenty-sdk": "0.0.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"
}
}
@@ -0,0 +1,500 @@
import axios from 'axios';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
import { type stripeCustomer, type stripeEvent, type stripeStatus, type twentyObject } from './types';
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
const TWENTY_API_URL: string =
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
? `${process.env.TWENTY_API_URL}/rest`
: 'https://api.twenty.com/rest';
const STRIPE_API_KEY: string = process.env.STRIPE_API_KEY ?? '';
const STRIPE_API_URL: string = 'https://api.stripe.com/v1/customers';
const getTwentyObjectData = async (
objectSingularName: string,
): Promise<twentyObject | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/metadata/objects`,
};
try {
const response = await axios.request(options);
if (response.status === 200) {
const companyObject = response.data.data.objects.find(
(object: twentyObject) => object.nameSingular === objectSingularName,
);
return (companyObject as twentyObject) ?? ({} as twentyObject);
}
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const createFields = async (objectId: string, fieldName: string) => {
const data =
fieldName === 'seats'
? {
type: 'NUMBER',
objectMetadataId: objectId,
name: 'seats',
label: 'Seats',
icon: 'IconMan',
}
: {
type: 'SELECT',
objectMetadataId: objectId,
name: 'subStatus',
label: 'Sub Status',
icon: 'IconStatusChange',
options: [
{
color: 'iris',
label: 'Incomplete',
value: 'INCOMPLETE',
position: 1,
},
{
color: 'sky',
label: 'Incomplete (expired)',
value: 'INCOMPLETE_EXPIRED',
position: 2,
},
{
color: 'amber',
label: 'Trialing',
value: 'TRIALING',
position: 3,
},
{
color: 'green',
label: 'Active',
value: 'ACTIVE',
position: 4,
},
{
color: 'orange',
label: 'Past due',
value: 'PAST_DUE',
position: 5,
},
{
color: 'brown',
label: 'Canceled',
value: 'CANCELED',
position: 6,
},
{
color: 'red',
label: 'Unpaid',
value: 'UNPAID',
position: 7,
},
{
color: 'gray',
label: 'Paused',
value: 'PAUSED',
position: 8,
},
],
};
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/metadata/fields`,
data: data,
};
try {
const response = await axios(options);
return response.status === 201;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const getStripeCustomerData = async (
customerID: string,
): Promise<stripeCustomer | undefined> => {
const options = {
method: 'GET',
url: `${STRIPE_API_URL}/${customerID}`,
auth: {
username: STRIPE_API_KEY,
password: '',
},
};
try {
const response = await axios(options);
return response.status === 200
? ({
name: response.data.name,
businessName: response.data.business_name,
email: response.data.email,
} as stripeCustomer)
: ({} as stripeCustomer);
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfCompanyExistsInTwenty = async (
name: string | undefined,
): Promise<string | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/companies?filter=name%5Beq%5D%3A%22${name}%22`,
};
try {
const response = await axios(options);
return response.status === 200 &&
response.data.data.companies[0].id !== undefined
? (response.data.data.companies[0].id as string)
: '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateTwentyCompany = async (
companyId: string | undefined,
seats: number | null,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/companies/${companyId}`,
data: {
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const createTwentyCompany = async (
customerName: string | undefined,
seats: number | null,
subStatus: string,
): Promise<string | undefined> => {
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/companies`,
data: {
name: customerName,
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios(options);
return response.status === 201 ? (response.data.data.id as string) : '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfStripePersonExistsInTwenty = async (email: string | null) => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people?filter=emails.primaryEmail%5Beq%5D%3A%22${email}%22`, // mail is unique by default so there can be only 1 person with given mail
};
try {
const response = await axios.request(options);
return response.status === 200 &&
response.data.data.people[0].id !== undefined
? (response.data.data.people[0].id as string)
: '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const addTwentyPerson = async (
firstName: string,
lastName: string,
email: string,
companyId: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people`,
data: {
firstName: firstName,
lastName: lastName,
emails: { primaryEmail: email },
companyId: companyId,
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios.request(options);
return response.status === 201;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateTwentyPerson = async (
id: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people/${id}`,
data: {
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
export const main = async (
params: Record<string, any>,
): Promise<object | undefined> => {
if (TWENTY_API_KEY === '' || STRIPE_API_KEY === '') {
throw new Error('Missing variables');
}
try {
// TODO: add validation of signature key from Stripe (not possible at the moment as headers aren't accessible in serverless functions)
const stripe = params as stripeEvent;
const allowed_types: string[] = [
'customer.subscription.created',
'customer.subscription.updated',
];
if (!allowed_types.includes(stripe.type)) {
throw new Error('Wrong type of webhook');
}
const stripeCustomer: stripeCustomer | undefined =
await getStripeCustomerData(stripe.data.object.customer);
if (
stripeCustomer?.businessName === undefined ||
stripeCustomer?.businessName === ''
) {
console.warn('Set customer business name in Stripe');
return {};
}
const companyObject = await getTwentyObjectData('company');
if (
companyObject?.fields.find((field) => field.name === 'seats') ===
undefined
) {
const seatsFieldCreated: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'seats')
: false;
if (!seatsFieldCreated) {
throw new Error('Seats field creation in Company object failed');
} else {
console.info('Seats field creation in Company object succeeded');
}
}
if (
companyObject?.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const subStatusFieldCreated: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'subStatus')
: false;
if (!subStatusFieldCreated) {
throw new Error('Sub status field creation in Company object failed');
} else {
console.info('Sub status field creation in Company object succeeded');
}
}
const personObject = await getTwentyObjectData('person');
if (
personObject?.fields.find((field) => field.name === 'seats') === undefined
) {
const seatsFieldCreated: boolean | undefined = personObject?.id
? await createFields(personObject?.id, 'seats')
: false;
if (!seatsFieldCreated) {
throw new Error('Seats field creation in People object failed');
} else {
console.info('Seats field creation in People object succeeded');
}
}
if (
personObject?.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const subStatusFieldCreated: boolean | undefined = personObject?.id
? await createFields(personObject?.id, 'subStatus')
: false;
if (!subStatusFieldCreated) {
throw new Error('Sub status field creation in People object failed');
} else {
console.info('Sub status field creation in People object succeeded');
}
}
const twentyCompanyId: string | undefined =
await checkIfCompanyExistsInTwenty(stripeCustomer?.businessName);
const seats: number =
stripe.data.object.quantity ??
stripe.data.object.items.data.reduce(
(acc, item) => acc + item.quantity,
0,
); // we don't know if subscription has only 1 item (product) or more
let updatedTwentyCompanyId: string | undefined;
if (twentyCompanyId === '') {
const twentyCompanyCreated: string | undefined =
await createTwentyCompany(
stripeCustomer?.businessName,
seats,
stripe.data.object.status.toUpperCase(),
);
if (twentyCompanyCreated === '') {
throw new Error('Creation of Stripe customer in Twenty failed');
} else {
console.log('Creation of Stripe customer in Twenty succeeded');
updatedTwentyCompanyId = twentyCompanyCreated;
}
} else {
const twentyCompanyUpdated: boolean | undefined =
await updateTwentyCompany(
twentyCompanyId,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!twentyCompanyUpdated) {
throw new Error('Update of Stripe customer in Twenty failed');
} else {
console.log('Update of Stripe customer in Twenty succeeded');
updatedTwentyCompanyId = twentyCompanyId;
}
}
if (updatedTwentyCompanyId === undefined || updatedTwentyCompanyId === '') {
throw new Error('TwentyCompanyId not found');
} else {
const stripeCustomerInTwenty: string | undefined =
await checkIfStripePersonExistsInTwenty(stripeCustomer.email);
if (stripeCustomerInTwenty === '') {
if (!stripeCustomer.name) {
throw new Error('Missing Stripe customer first or last name');
}
if (!stripeCustomer.email) {
throw new Error('Missing Stripe customer email');
}
const firstName: string = stripeCustomer.name?.split(' ')[0];
const lastName: string = stripeCustomer.name?.split(' ')[1];
const addedStripePersonToTwenty: boolean | undefined =
await addTwentyPerson(
firstName,
lastName,
stripeCustomer.email,
updatedTwentyCompanyId,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!addedStripePersonToTwenty) {
throw new Error('Adding Stripe person to Twenty failed');
} else {
console.log('Stripe person was added to Twenty');
}
} else if (stripeCustomerInTwenty !== undefined) {
const updatedStripePersonInTwenty: boolean | undefined =
await updateTwentyPerson(
stripeCustomerInTwenty,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!updatedStripePersonInTwenty) {
throw new Error('Update of Stripe person in Twenty failed');
} else {
console.log('Update of Stripe person in Twenty succeeded');
}
} else {
throw new Error('Twenty not found');
}
}
return {};
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.message);
return {};
}
console.error(error);
return {};
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'cd15a738-18a5-406e-8b83-959dc52ebe14',
name: 'stripe',
triggers: [
{
universalIdentifier: '55f58e19-d832-43c4-9f8b-3f29fc05c162',
type: 'route',
path: '/webhook/stripe',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};
@@ -1,505 +0,0 @@
import axios from 'axios';
import { type FunctionConfig } from 'twenty-sdk';
import {
type stripeCustomer,
type stripeEvent,
type stripeStatus,
type twentyObject,
} from './types';
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
const TWENTY_API_URL: string =
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
? `${process.env.TWENTY_API_URL}/rest`
: 'https://api.twenty.com/rest';
const STRIPE_API_KEY: string = process.env.STRIPE_API_KEY ?? '';
const STRIPE_API_URL: string = 'https://api.stripe.com/v1/customers';
const getTwentyObjectData = async (
objectSingularName: string,
): Promise<twentyObject | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/metadata/objects`,
};
try {
const response = await axios.request(options);
if (response.status === 200) {
const companyObject = response.data.data.objects.find(
(object: twentyObject) => object.nameSingular === objectSingularName,
);
return (companyObject as twentyObject) ?? ({} as twentyObject);
}
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const createFields = async (objectId: string, fieldName: string) => {
const data =
fieldName === 'seats'
? {
type: 'NUMBER',
objectMetadataId: objectId,
name: 'seats',
label: 'Seats',
icon: 'IconMan',
}
: {
type: 'SELECT',
objectMetadataId: objectId,
name: 'subStatus',
label: 'Sub Status',
icon: 'IconStatusChange',
options: [
{
color: 'iris',
label: 'Incomplete',
value: 'INCOMPLETE',
position: 1,
},
{
color: 'sky',
label: 'Incomplete (expired)',
value: 'INCOMPLETE_EXPIRED',
position: 2,
},
{
color: 'amber',
label: 'Trialing',
value: 'TRIALING',
position: 3,
},
{
color: 'green',
label: 'Active',
value: 'ACTIVE',
position: 4,
},
{
color: 'orange',
label: 'Past due',
value: 'PAST_DUE',
position: 5,
},
{
color: 'brown',
label: 'Canceled',
value: 'CANCELED',
position: 6,
},
{
color: 'red',
label: 'Unpaid',
value: 'UNPAID',
position: 7,
},
{
color: 'gray',
label: 'Paused',
value: 'PAUSED',
position: 8,
},
],
};
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/metadata/fields`,
data: data,
};
try {
const response = await axios(options);
return response.status === 201;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const getStripeCustomerData = async (
customerID: string,
): Promise<stripeCustomer | undefined> => {
const options = {
method: 'GET',
url: `${STRIPE_API_URL}/${customerID}`,
auth: {
username: STRIPE_API_KEY,
password: '',
},
};
try {
const response = await axios(options);
return response.status === 200
? ({
name: response.data.name,
businessName: response.data.business_name,
email: response.data.email,
} as stripeCustomer)
: ({} as stripeCustomer);
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfCompanyExistsInTwenty = async (
name: string | undefined,
): Promise<string | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/companies?filter=name%5Beq%5D%3A%22${name}%22`,
};
try {
const response = await axios(options);
return response.status === 200 &&
response.data.data.companies[0].id !== undefined
? (response.data.data.companies[0].id as string)
: '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateTwentyCompany = async (
companyId: string | undefined,
seats: number | null,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/companies/${companyId}`,
data: {
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const createTwentyCompany = async (
customerName: string | undefined,
seats: number | null,
subStatus: string,
): Promise<string | undefined> => {
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/companies`,
data: {
name: customerName,
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios(options);
return response.status === 201 ? (response.data.data.id as string) : '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfStripePersonExistsInTwenty = async (email: string | null) => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people?filter=emails.primaryEmail%5Beq%5D%3A%22${email}%22`, // mail is unique by default so there can be only 1 person with given mail
};
try {
const response = await axios.request(options);
return response.status === 200 &&
response.data.data.people[0].id !== undefined
? (response.data.data.people[0].id as string)
: '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const addTwentyPerson = async (
firstName: string,
lastName: string,
email: string,
companyId: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people`,
data: {
firstName: firstName,
lastName: lastName,
emails: { primaryEmail: email },
companyId: companyId,
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios.request(options);
return response.status === 201;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateTwentyPerson = async (
id: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people/${id}`,
data: {
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
export const main = async (
params: Record<string, any>,
): Promise<object | undefined> => {
if (TWENTY_API_KEY === '' || STRIPE_API_KEY === '') {
throw new Error('Missing variables');
}
try {
// TODO: add validation of signature key from Stripe (not possible at the moment as headers aren't accessible in serverless functions)
const stripe = params as stripeEvent;
const allowed_types: string[] = [
'customer.subscription.created',
'customer.subscription.updated',
];
if (!allowed_types.includes(stripe.type)) {
throw new Error('Wrong type of webhook');
}
const stripeCustomer: stripeCustomer | undefined =
await getStripeCustomerData(stripe.data.object.customer);
if (
stripeCustomer?.businessName === undefined ||
stripeCustomer?.businessName === ''
) {
console.warn('Set customer business name in Stripe');
return {};
}
const companyObject = await getTwentyObjectData('company');
if (
companyObject?.fields.find((field) => field.name === 'seats') ===
undefined
) {
const seatsFieldCreated: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'seats')
: false;
if (!seatsFieldCreated) {
throw new Error('Seats field creation in Company object failed');
} else {
console.info('Seats field creation in Company object succeeded');
}
}
if (
companyObject?.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const subStatusFieldCreated: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'subStatus')
: false;
if (!subStatusFieldCreated) {
throw new Error('Sub status field creation in Company object failed');
} else {
console.info('Sub status field creation in Company object succeeded');
}
}
const personObject = await getTwentyObjectData('person');
if (
personObject?.fields.find((field) => field.name === 'seats') === undefined
) {
const seatsFieldCreated: boolean | undefined = personObject?.id
? await createFields(personObject?.id, 'seats')
: false;
if (!seatsFieldCreated) {
throw new Error('Seats field creation in People object failed');
} else {
console.info('Seats field creation in People object succeeded');
}
}
if (
personObject?.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const subStatusFieldCreated: boolean | undefined = personObject?.id
? await createFields(personObject?.id, 'subStatus')
: false;
if (!subStatusFieldCreated) {
throw new Error('Sub status field creation in People object failed');
} else {
console.info('Sub status field creation in People object succeeded');
}
}
const twentyCompanyId: string | undefined =
await checkIfCompanyExistsInTwenty(stripeCustomer?.businessName);
const seats: number =
stripe.data.object.quantity ??
stripe.data.object.items.data.reduce(
(acc, item) => acc + item.quantity,
0,
); // we don't know if subscription has only 1 item (product) or more
let updatedTwentyCompanyId: string | undefined;
if (twentyCompanyId === '') {
const twentyCompanyCreated: string | undefined =
await createTwentyCompany(
stripeCustomer?.businessName,
seats,
stripe.data.object.status.toUpperCase(),
);
if (twentyCompanyCreated === '') {
throw new Error('Creation of Stripe customer in Twenty failed');
} else {
console.log('Creation of Stripe customer in Twenty succeeded');
updatedTwentyCompanyId = twentyCompanyCreated;
}
} else {
const twentyCompanyUpdated: boolean | undefined =
await updateTwentyCompany(
twentyCompanyId,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!twentyCompanyUpdated) {
throw new Error('Update of Stripe customer in Twenty failed');
} else {
console.log('Update of Stripe customer in Twenty succeeded');
updatedTwentyCompanyId = twentyCompanyId;
}
}
if (updatedTwentyCompanyId === undefined || updatedTwentyCompanyId === '') {
throw new Error('TwentyCompanyId not found');
} else {
const stripeCustomerInTwenty: string | undefined =
await checkIfStripePersonExistsInTwenty(stripeCustomer.email);
if (stripeCustomerInTwenty === '') {
if (!stripeCustomer.name) {
throw new Error('Missing Stripe customer first or last name');
}
if (!stripeCustomer.email) {
throw new Error('Missing Stripe customer email');
}
const firstName: string = stripeCustomer.name?.split(' ')[0];
const lastName: string = stripeCustomer.name?.split(' ')[1];
const addedStripePersonToTwenty: boolean | undefined =
await addTwentyPerson(
firstName,
lastName,
stripeCustomer.email,
updatedTwentyCompanyId,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!addedStripePersonToTwenty) {
throw new Error('Adding Stripe person to Twenty failed');
} else {
console.log('Stripe person was added to Twenty');
}
} else if (stripeCustomerInTwenty !== undefined) {
const updatedStripePersonInTwenty: boolean | undefined =
await updateTwentyPerson(
stripeCustomerInTwenty,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!updatedStripePersonInTwenty) {
throw new Error('Update of Stripe person in Twenty failed');
} else {
console.log('Update of Stripe person in Twenty succeeded');
}
} else {
throw new Error('Twenty not found');
}
}
return {};
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.message);
return {};
}
console.error(error);
return {};
}
};
export const config: FunctionConfig = {
universalIdentifier: 'cd15a738-18a5-406e-8b83-959dc52ebe14',
name: 'stripe',
triggers: [
{
universalIdentifier: '55f58e19-d832-43c4-9f8b-3f29fc05c162',
type: 'route',
path: '/webhook/stripe',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
@@ -0,0 +1,36 @@
# Updated by
Updates Updated by field with details of person behind newest update
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
## Quick start
1. Add application
```bash
twenty auth login
cd packages/twenty-apps/updated-by
twenty app sync
```
2. Configure **TWENTY_API_KEY**
Go to Settings > Applications > Updated by > Settings and add Twenty API key used to
send requests to Twenty.
**If you're using self-hosted instance, you have to add also URL to your workspace.**
## Flow
1. Check if Twenty API key is added, if not, exit
2. Check if updated record belongs to an object which shouldn't have a `updatedBy` field (like blocklists or messages), if yes, exit
3. Check if updated record has updatedBy field, if not, create it
4. Check if updated field in record is updatedBy field, if yes, return preemptively
5. Update record with workspace member ID
## Notes
- Updated by field shouldn't be changed by users, only by extension
- Amount of API requests is reduced to possible minimum
@@ -0,0 +1,23 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: "113d22d6-6711-4c1f-807d-b69203d3a503",
displayName: "updated_by",
description: "Updates Updated by field with details of person behind newest update",
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: "23df8d62-22b9-4d8e-9af9-b48c88a3c41b",
isSecret: true,
value: "",
description: "Required, used to send requests to Twenty"
},
TWENTY_API_URL: {
universalIdentifier: "aa8b9a8b-aded-48f2-bc30-fe9f0e6f4c60",
isSecret: false,
value: "",
description: "Optional, defaults to cloud API URL"
},
},
}
export default config;
@@ -0,0 +1,15 @@
{
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"dependencies": {
"axios": "^1.13.1",
"twenty-sdk": "^0.0.4"
}
}
@@ -0,0 +1,237 @@
import axios from 'axios';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
const TWENTY_API_URL: string =
process.env.TWENTY_API_URL ?? 'https://api.twenty.com';
const objectsNotForUpdate: string[] = [
'messageParticipants',
'blocklists',
'favoriteFolders',
'calendarEvents',
'attachments',
'workflowRuns',
'workflowAutomatedTriggers',
'messages',
'timelineActivities',
'workflows',
'calendarChannels',
'calendarChannelEventAssociations',
'viewFilters',
'viewFields',
'calendarEventParticipants',
'messageChannels',
'workspaceMembers',
'messageFolders',
'connectedAccounts',
'viewFilterGroups',
'noteTargets',
'views',
'workflowVersions',
'taskTargets',
'viewSorts',
'messageThreads',
'favorites',
'messageChannelMessageAssociations',
'viewGroups',
];
const returnWorkspaceMemberObjectId = async (): Promise<string> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/rest/metadata/objects`,
};
const response = await axios.request(options);
return response.data.data.objects.find(
(object: any) => object.namePlural === 'workspaceMembers',
).id;
};
const createUpdatedByFieldMetadata = async (
sourceObjectId: string,
workspaceMemberObjectId: string,
objectName: string,
): Promise<boolean | undefined> => {
// taken directly from Network tab
const GraphQLQuery: string = `mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {
createOneField(input: $input) {
id
type
name
label
description
icon
isCustom
isActive
isUnique
isNullable
createdAt
updatedAt
settings
defaultValue
options
isLabelSyncedWithName
object {
id
__typename
}
__typename
}
}`;
const variables = {
input: {
field: {
description: 'Shows the person behind newest update',
icon: 'IconRelationOneToMany',
label: 'Updated by',
name: 'updatedBy',
isLabelSyncedWithName: true,
objectMetadataId: `${sourceObjectId}`,
type: 'RELATION',
relationCreationPayload: {
type: 'MANY_TO_ONE',
targetObjectMetadataId: `${workspaceMemberObjectId}`,
targetFieldLabel: `Updated by ${objectName}`,
targetFieldIcon: 'IconUsers',
},
},
},
};
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/graphql`,
data: {
query: GraphQLQuery,
variables: variables,
},
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateUpdatedByFieldValue = async (
objectName: string,
workspaceMemberId: string,
recordId: string,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/rest/${objectName}/${recordId}`,
data: {
updatedById: workspaceMemberId,
},
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
export const main = async (params: {
properties: Record<string, any>;
objectMetadata: Record<string, any>;
recordId: string;
workspaceMemberId: string;
}): Promise<object> => {
if (TWENTY_API_KEY === '') {
console.error('You must specify a valid TWENTY_API_KEY');
return {};
}
try {
const { properties, objectMetadata, recordId, workspaceMemberId } = params;
if (objectsNotForUpdate.includes(objectMetadata.namePlural)) {
console.log('Updated object is immutable system object');
return {};
}
if (!workspaceMemberId) {
console.log('Exited as last update was done via API');
return {};
}
if (
properties.updatedFields?.length === 1 &&
properties.updatedFields.includes('updatedById')
) {
console.log('Exited as last update was done by serverless function');
return {}; // if last update was updatedBy field, don't update
}
if (objectMetadata.fieldIdByJoinColumnName.updatedById === undefined) {
const workspaceMemberObjectId: string =
await returnWorkspaceMemberObjectId();
const isFieldCreated: boolean | undefined = await createUpdatedByFieldMetadata(
objectMetadata.id,
workspaceMemberObjectId,
objectMetadata.namePlural,
);
if (!isFieldCreated) {
console.error(`Creation of updated by field in ${objectMetadata.namePlural} failed`);
return {};
}
else {
console.log(`Updated by field in ${objectMetadata.namePlural} has been successfully created`);
}
}
const isObjectUpdated: boolean | undefined = await updateUpdatedByFieldValue(
objectMetadata.namePlural,
workspaceMemberId,
recordId,
);
if (isObjectUpdated) {
console.log(
`Field updatedBy in record ${recordId} in object ${objectMetadata.namePlural} has been updated`,
);
return {};
}
throw new Error(
`Update field updatedBy in record ${recordId} in object ${objectMetadata.namePlural} has failed!`,
);
} catch (error) {
console.error('Exiting because of error');
if (axios.isAxiosError(error)) {
console.error(error.message);
} else {
console.error(error);
}
return {};
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '47005bbc-ed0d-4d04-b53b-e94f5c38656d',
name: 'updated-by',
triggers: [
{
universalIdentifier: '0b6da7cf-506f-4cb9-b692-a44b08972ba4',
type: 'databaseEvent',
eventName: '*.updated',
},
],
};
@@ -0,0 +1,26 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -0,0 +1,256 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
languageName: node
linkType: hard
"async-generator-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-generator-function@npm:1.0.0"
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
languageName: node
linkType: hard
"asynckit@npm:^0.4.0":
version: 0.4.0
resolution: "asynckit@npm:0.4.0"
checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
languageName: node
linkType: hard
"axios@npm:^1.13.1":
version: 1.13.1
resolution: "axios@npm:1.13.1"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.4"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/de9c3c6de43d3ee1146d3afe78645f19450cac6a5d7235bef8b8e8eeb705c2e47e2d231dea99cecaec4dae1897c521118ca9413b9d474063c719c4d94c5b9adc
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
dependencies:
delayed-stream: "npm:~1.0.0"
checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
languageName: node
linkType: hard
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.3.0":
version: 1.3.0
resolution: "es-errors@npm:1.3.0"
checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85
languageName: node
linkType: hard
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.1.0":
version: 2.1.0
resolution: "es-set-tostringtag@npm:2.1.0"
dependencies:
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.6"
has-tostringtag: "npm:^1.0.2"
hasown: "npm:^2.0.2"
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
languageName: node
linkType: hard
"follow-redirects@npm:^1.15.6":
version: 1.15.11
resolution: "follow-redirects@npm:1.15.11"
peerDependenciesMeta:
debug:
optional: true
checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
languageName: node
linkType: hard
"form-data@npm:^4.0.4":
version: 4.0.4
resolution: "form-data@npm:4.0.4"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5
languageName: node
linkType: hard
"generator-function@npm:^2.0.0":
version: 2.0.1
resolution: "generator-function@npm:2.0.1"
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.6":
version: 1.3.1
resolution: "get-intrinsic@npm:1.3.1"
dependencies:
async-function: "npm:^1.0.0"
async-generator-function: "npm:^1.0.0"
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
generator-function: "npm:^2.0.0"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
languageName: node
linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.2":
version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2"
dependencies:
has-symbols: "npm:^1.0.3"
checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c
languageName: node
linkType: hard
"hasown@npm:^2.0.2":
version: 2.0.2
resolution: "hasown@npm:2.0.2"
dependencies:
function-bind: "npm:^1.1.2"
checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9
languageName: node
linkType: hard
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
languageName: node
linkType: hard
"mime-db@npm:1.52.0":
version: 1.52.0
resolution: "mime-db@npm:1.52.0"
checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
languageName: node
linkType: hard
"mime-types@npm:^2.1.12":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
mime-db: "npm:1.52.0"
checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
languageName: node
linkType: hard
"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
languageName: node
linkType: hard
"root-workspace-0b6124@workspace:.":
version: 0.0.0-use.local
resolution: "root-workspace-0b6124@workspace:."
dependencies:
axios: "npm:^1.13.1"
twenty-sdk: "npm:^0.0.4"
languageName: unknown
linkType: soft
"twenty-sdk@npm:^0.0.4":
version: 0.0.4
resolution: "twenty-sdk@npm:0.0.4"
checksum: 10c0/550f1d85bf0701396c9dd2d4c6bc55ba1b067fce13636f8540eec60ab6a4257c6d7cd86cb3f62e0974bf99467bc31270d92b17b9681a1b7a6281b7ef97224080
languageName: node
linkType: hard
@@ -0,0 +1,22 @@
import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Hello World',
description: 'A simple hello world app',
icon: 'IconWorld',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
description: 'Twenty API Key',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: 'ef8ab489-e68a-4841-b402-261f440e6185',
description: 'Twenty API Url',
isSecret: false,
},
},
};
export default config;
@@ -1,6 +1,6 @@
{
"name": "hello-world",
"version": "0.2.2",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -17,7 +17,7 @@
"auth": "twenty auth login"
},
"dependencies": {
"twenty-sdk": "0.2.4"
"twenty-sdk": "0.1.2"
},
"devDependencies": {
"@types/node": "^24.7.2"
@@ -1,30 +1,21 @@
import type {
FunctionConfig,
DatabaseEventPayload,
ObjectRecordCreateEvent,
CronPayload,
} from 'twenty-sdk';
import Twenty, { type Person } from '../../generated';
import { type FunctionConfig } from 'twenty-sdk';
import { createClient } from '../../generated';
type CreateNewPostCardParams =
| { name?: string }
| DatabaseEventPayload<ObjectRecordCreateEvent<Person>>
| CronPayload;
export const main = async (params: CreateNewPostCardParams) => {
export const main = async (params: { recipient?: string }) => {
try {
const client = new Twenty();
const name =
'name' in params
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
const client = createClient({
url: `${process.env.TWENTY_API_URL}/graphql`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
const createPostCard = await client.mutation({
createPostCard: {
__args: {
data: {
name,
name: params.recipient ?? 'Hello-world',
},
},
name: true,
@@ -1,19 +0,0 @@
import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Hello World',
description: 'A simple hello world app',
icon: 'IconWorld',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
description: 'Default recipient name for postcards',
value: 'Alex Karp',
isSecret: false,
},
},
functionRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
};
export default config;
@@ -1,33 +0,0 @@
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
export const functionRole: RoleConfig = {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
description: 'Default role for function Twenty client',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectNameSingular: 'postCard',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectNameSingular: 'postCard',
fieldName: 'content',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
};
+5 -15
View File
@@ -1021,15 +1021,6 @@ __metadata:
languageName: node
linkType: hard
"graphql-sse@npm:^2.5.4":
version: 2.6.0
resolution: "graphql-sse@npm:2.6.0"
peerDependencies:
graphql: ">=0.11 <=16"
checksum: 10c0/e05f0b5c8539d61e5ce34af8e0bb418c02bf922d6a7f9232a9abd53c77df5654684ca14f674ac771645c8fba9c37ce666c5d06f46eef54ea07e63653468065b0
languageName: node
linkType: hard
"graphql@npm:^16.6.0, graphql@npm:^16.8.1":
version: 16.12.0
resolution: "graphql@npm:16.12.0"
@@ -1083,7 +1074,7 @@ __metadata:
resolution: "hello-world@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
twenty-sdk: "npm:0.2.4"
twenty-sdk: "npm:0.1.2"
languageName: unknown
linkType: soft
@@ -1904,9 +1895,9 @@ __metadata:
languageName: node
linkType: hard
"twenty-sdk@npm:0.2.4":
version: 0.2.4
resolution: "twenty-sdk@npm:0.2.4"
"twenty-sdk@npm:0.1.2":
version: 0.1.2
resolution: "twenty-sdk@npm:0.1.2"
dependencies:
"@genql/cli": "npm:^3.0.3"
axios: "npm:^1.6.0"
@@ -1916,7 +1907,6 @@ __metadata:
dotenv: "npm:^16.4.0"
fs-extra: "npm:^11.2.0"
graphql: "npm:^16.8.1"
graphql-sse: "npm:^2.5.4"
inquirer: "npm:^10.0.0"
jsonc-parser: "npm:^3.2.0"
lodash.camelcase: "npm:^4.3.0"
@@ -1927,7 +1917,7 @@ __metadata:
uuid: "npm:^13.0.0"
bin:
twenty: dist/cli.cjs
checksum: 10c0/91bcc2c6a96d8bb7997c6584de338d370ffd4cebbc052ffdd5dd8bb8894f1851c039601a2ce0bb0f3b97e210f3bc877de0572125b8c3b601f6e1baac2f99a8c5
checksum: 10c0/18cb8c589b6d6c6e59d58c28eae333718d62df485d827ba41e99421ef9fb359c3f71fa42d80b1e5463d2140b68de084aee81bf541e70c0d04a246c332e2978e7
languageName: node
linkType: hard
+82 -1
View File
@@ -1,8 +1,89 @@
# Deprecated: twenty-cli
This package is deprecated. Please install and use [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) instead:
This package is deprecated. Please install and use twenty-sdk instead:
```bash
npm uninstall twenty-cli
npm install -g twenty-sdk
```
The command name remains the same: twenty.
A command-line interface to easily scaffold, develop, and publish applications that extend Twenty CRM (now provided by twenty-sdk).
## Requirements
- yarn >= 4.9.2
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
## Quick example project
```bash
# Authenticate using your apiKey (CLI will prompt for your <apiKey>)
twenty auth login
# Init a new application called hello-world
twenty app init hello-world
# Go to your app
cd hello-world
# Add a serverless function to your application
twenty app add serverlessFunction
# Add a trigger to your serverless function
twenty app add trigger
# Add axios to your application
yarn add axios
# Start dev mode: automatically syncs changes to your Twenty workspace, so you can test new functions/objects instantly.
twenty app dev
# Or use one time sync (also generates SDK automatically)
twenty app sync
# List all available commands
twenty help
```
## Application Structure
Each application in this package follows the standard application structure:
```
app-name/
├── package.json
├── README.md
├── serverlessFunctions # Custom backend logic (runs on demand)
└── ...
```
## Publish your application
Applications are currently stored in twenty/packages/twenty-apps.
You can share your application with all twenty users.
```bash
# pull 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.
## Contributing
- see our [Hacktoberfest 2025 notion page](https://twentycrm.notion.site/Hacktoberfest-27711d8417038037a149d4638a9cc510)
- our [Discord](https://discord.gg/cx5n4Jzs57)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-cli",
"version": "0.3.1",
"version": "0.3.0",
"description": "[DEPRECATED] Use twenty-sdk instead: https://www.npmjs.com/package/twenty-sdk",
"scripts": {
"build": "echo 'use npx nx build'",
+3 -61
View File
@@ -1,21 +1,15 @@
# Makefile for building and running Twenty CRM docker containers.
# Makefile for building Twenty CRM docker images.
# Set the tag and/or target build platform using make command-line variables assignments.
#
# Optional make variables:
# PLATFORM - defaults to 'linux/amd64'
# TAG - defaults to 'latest'
#
# Example: make prod-build
# Example: make PLATFORM=linux/aarch64 TAG=my-tag prod-build
# Example: make postgres-on-docker (for local development)
# Example: make
# Example: make PLATFORM=linux/aarch64 TAG=my-tag
PLATFORM ?= linux/amd64
TAG ?= latest
DOCKER_NETWORK=twenty_network
# =============================================================================
# Production Image Building
# =============================================================================
prod-build:
@cd ../.. && docker build -f ./packages/twenty-docker/twenty/Dockerfile --platform $(PLATFORM) --tag twenty:$(TAG) . && cd -
@@ -31,55 +25,3 @@ prod-website-build:
prod-website-run:
@docker run -d -p 3000:3000 --name twenty-website twenty-website:$(TAG)
# =============================================================================
# Local Development Services
# Run these from the repository root: make -C packages/twenty-docker <target>
# =============================================================================
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)/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)/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml \
otel/opentelemetry-collector-contrib:latest \
--config /etc/otel-collector-config.yaml
@@ -1,7 +0,0 @@
apiVersion: v2
name: twenty
description: A Helm chart to deploy Twenty CRM (server + worker) with optional PostgreSQL and Redis dependencies.
type: application
version: 0.1.0
appVersion: "v1.14.0"
icon: https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg
@@ -1,51 +0,0 @@
# Twenty Helm Chart - Quick Install
## Simple Install
Set your domain and install:
```bash
export DOMAIN=crm.example.com
helm install my-twenty ./packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace --wait \
--set "server.ingress.hosts[0].host=$DOMAIN" \
--set "server.ingress.tls[0].hosts[0]=$DOMAIN"
```
That's it! The chart will:
- Auto-generate a secure access token
- Create the PostgreSQL database "twenty" and schema "core" automatically
- Run TypeORM migrations via server init
- Enable TLS via cert-manager (acme: true by default for letsencrypt-prod)
## Access the App
Visit: `https://$DOMAIN`
Sign up to create your admin account through the web UI.
## Retrieve System Credentials
App secret token (for configuration/integrations):
```bash
kubectl get secret tokens -n twentycrm -o jsonpath='{.data.accessToken}' | base64 --decode && echo
```
Internal PostgreSQL credentials are managed by the chart and not exposed by default. If you need direct access, create your own user in the database pod or use an external PostgreSQL instance.
Jobs for DB creation and migrations have been removed to simplify deployments; the server handles readiness and migrations at startup.
## Advanced Configuration
See [full README](README.md) for:
- External PostgreSQL/Redis
- S3 storage configuration
- Custom resource limits
## Uninstall
```bash
helm uninstall my-twenty -n twentycrm
kubectl delete namespace twentycrm
```
@@ -1,86 +0,0 @@
# Twenty Helm Chart
Deploy Twenty CRM on Kubernetes with server, worker, PostgreSQL, and Redis components.
## Features
- Server and worker deployments with full env exposure via `values.yaml`.
- Internal PostgreSQL (Spilo) and Redis deployments included.
- PVC-based persistence using dynamic storage classes (no static PV manifests).
- Ingress with configurable annotations, hosts, and TLS.
- Database readiness and migrations handled by server/worker init containers by default.
Standard Kubernetes Jobs for DB creation/user and migrations have been removed to simplify installs. Readiness and migrations run in init containers.
## Quick Start
See [QUICKSTART.md](QUICKSTART.md) for a simple 2-line install with your domain.
## Installing
**Prerequisites:** Kubernetes 1.21+, Helm 3.8+, default StorageClass
Internal DB + Redis (default):
```bash
helm install my-twenty ./packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace
```
External DB/Redis:
```bash
helm install my-twenty ./packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace \
--set db.enabled=false \
--set db.external.host=db.example.com \
--set redisInternal.enabled=false
```
## Key Values
See `values.yaml` for a comprehensive list.
## Notes
- Database URL and Redis URL are composed automatically from chart settings
- Database `twenty` and schema `core` are created automatically by server init container
- No optional jobs: the chart no longer provides separate Jobs for DB or migrations.
- Access token auto-generated (32 chars) if not provided; reuses existing secret if present
- For production, provide a strong `secrets.tokens.accessToken` value via a secure values file; the auto-generated token is a convenience fallback.
- TLS enabled by default via cert-manager (`acme: true`)
- Requires default StorageClass for PVC provisioning
## Testing
```bash
helm lint ./packages/twenty-docker/helm/twenty
helm template my-twenty ./packages/twenty-docker/helm/twenty
helm plugin install https://github.com/quintush/helm-unittest
helm unittest ./packages/twenty-docker/helm/twenty
```
## Storage
**Local (default):** Uses PVCs for persistence
**S3:** Set `storage.type=s3` and provide credentials using a values file. You can either pass credentials directly or reference an existing Kubernetes Secret.
```bash
# values-secrets.yaml (do not commit)
# storage:
# type: s3
# s3:
# bucket: my-bucket
# region: us-east-1
# # Option A: direct values
# accessKeyId: AKIA...
# secretAccessKey: ...
# # Option B: reference a Secret
# # secretName: my-s3-creds
# # accessKeyIdKey: accessKeyId
# # secretAccessKeyKey: secretAccessKey
helm install my-twenty ./packages/twenty-docker/helm/twenty -f values-secrets.yaml
```
## Production Tips
- **Image versioning:** The chart defaults to `Chart.yaml`'s `appVersion` (currently v1.14.0). Override via `image.tag` in values to pin a different version or use `latest` for rolling updates.
- **Keep secrets secure:** Avoid `--set` for sensitive values; use `-f values-secrets.yaml` or reference existing Kubernetes Secrets via `server.extraEnvFrom`.
- S3 credentials can be referenced via `storage.s3.secretName + accessKeyIdKey/secretAccessKeyKey` to avoid embedding them in pod specs.
@@ -1,17 +0,0 @@
Thank you for installing Twenty!
To access the application:
- If ingress enabled:
{{- if and .Values.server.ingress.enabled (gt (len .Values.server.ingress.hosts) 0) }}
- Primary host: {{ (index .Values.server.ingress.hosts 0).host | default "<set a host>" }}
{{- else }}
- Primary host: <set a host>
{{- end }}
- URL: {{ include "twenty.serverUrl" . }}
- If ingress disabled: expose the service as needed (e.g., `kubectl port-forward svc/{{ include "twenty.fullname" . }}-server 3000:{{ .Values.server.service.port }}` or create a LoadBalancer/NodePort).
Configuration:
- Using internal DB: {{ ternary "yes" "no" .Values.db.enabled }}
- Using external DB: {{ ternary "yes" "no" (not .Values.db.enabled) }}
- Using internal Redis: {{ ternary "yes" "no" .Values.redisInternal.enabled }}
@@ -1,172 +0,0 @@
{{- define "twenty.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "twenty.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "twenty.namespace" -}}
{{ .Release.Namespace }}
{{- end -}}
{{/* Server image fields merged with globals */}}
{{- define "twenty.server.image" -}}
{{- $repo := default $.Values.image.repository (index $.Values.server.image "repository" | default "") -}}
{{- $tag := default (default $.Chart.AppVersion $.Values.image.tag) (index $.Values.server.image "tag" | default "") -}}
{{- $pp := default $.Values.image.pullPolicy (index $.Values.server.image "pullPolicy" | default "") -}}
{{- printf "%s:%s|%s" $repo $tag $pp -}}
{{- end -}}
{{/* Worker image fields merged with globals */}}
{{- define "twenty.worker.image" -}}
{{- $repo := default $.Values.image.repository (index $.Values.worker.image "repository" | default "") -}}
{{- $tag := default (default $.Chart.AppVersion $.Values.image.tag) (index $.Values.worker.image "tag" | default "") -}}
{{- $pp := default $.Values.image.pullPolicy (index $.Values.worker.image "pullPolicy" | default "") -}}
{{- printf "%s:%s|%s" $repo $tag $pp -}}
{{- end -}}
{{/* Extract parts of image helper */}}
{{- define "twenty.image.repository" -}}
{{- regexFind "^([^:|]+)" . -}}
{{- end -}}
{{- define "twenty.image.tag" -}}
{{- regexFind ":([^|]+)" . | trimPrefix ":" -}}
{{- end -}}
{{- define "twenty.image.pullPolicy" -}}
{{- regexFind "\\|(.+)$" . | trimPrefix "|" -}}
{{- end -}}
{{/* Compose DB connection URL */}}
{{- define "twenty.dbUrl" -}}
{{- if .Values.server.env.PG_DATABASE_URL -}}
{{- .Values.server.env.PG_DATABASE_URL -}}
{{- else if .Values.db.enabled -}}
{{- $host := printf "%s-db" (include "twenty.fullname" .) -}}
{{- $user := .Values.db.internal.appUser | default "twenty_app_user" -}}
{{- $pass := .Values.db.internal.appPassword | default (randAlphaNum 32) -}}
{{- $db := .Values.db.internal.database | default "twenty" -}}
{{- printf "postgres://%s:%s@%s.%s.svc.cluster.local/%s" $user $pass $host (include "twenty.namespace" .) $db -}}
{{- else -}}
{{- $scheme := "postgres" -}}
{{- $host := .Values.db.external.host -}}
{{- $port := .Values.db.external.port | default 5432 -}}
{{- $user := .Values.db.external.user | default "postgres" -}}
{{- $pass := .Values.db.external.password | default "postgres" -}}
{{- $db := .Values.db.external.database | default "twenty" -}}
{{- $qs := ternary "?sslmode=require" "" (eq .Values.db.external.ssl true) -}}
{{- printf "%s://%s:%s@%s:%v/%s%s" $scheme $user $pass $host $port $db $qs -}}
{{- end -}}
{{- end -}}
{{/* Compose Redis URL */}}
{{- define "twenty.redisUrl" -}}
{{- if .Values.server.env.REDIS_URL -}}
{{- .Values.server.env.REDIS_URL -}}
{{- else if .Values.redisInternal.enabled -}}
{{- $host := printf "%s-redis" (include "twenty.fullname" .) -}}
{{- printf "redis://%s.%s.svc.cluster.local:6379" $host (include "twenty.namespace" .) -}}
{{- else -}}
{{- $host := .Values.redis.external.host | default "redis" -}}
{{- $port := .Values.redis.external.port | default 6379 -}}
{{- printf "redis://%s:%v" $host $port -}}
{{- end -}}
{{- end -}}
{{/* Compose Server URL from ingress, else service */}}
{{- define "twenty.serverUrl" -}}
{{- if and .Values.server.ingress.enabled (gt (len .Values.server.ingress.hosts) 0) -}}
{{- $host := (index .Values.server.ingress.hosts 0).host -}}
{{- $tls := gt (len .Values.server.ingress.tls) 0 -}}
{{- $scheme := ternary "https" "http" $tls -}}
{{- $port := ternary 443 80 $tls -}}
{{- printf "%s://%s:%v" $scheme $host $port -}}
{{- else -}}
{{- $svc := printf "%s-server" (include "twenty.fullname" .) -}}
{{- $ns := include "twenty.namespace" . -}}
{{- $port := .Values.server.service.port | default 3000 -}}
{{- printf "http://%s.%s.svc.cluster.local:%v" $svc $ns $port -}}
{{- end -}}
{{- end -}}
{{/* Tokens secret name */}}
{{- define "twenty.secret.tokens.name" -}}
{{- .Values.secrets.tokens.name | default "tokens" -}}
{{- end -}}
{{/* Access token value: reuse existing secret if present, else provided value, else generated */}}
{{- define "twenty.secret.tokens.access" -}}
{{- $name := include "twenty.secret.tokens.name" . -}}
{{- $ns := include "twenty.namespace" . -}}
{{- $existing := lookup "v1" "Secret" $ns $name -}}
{{- if and $existing $existing.data.accessToken -}}
{{- b64dec $existing.data.accessToken -}}
{{- else if .Values.secrets.tokens.accessToken -}}
{{- .Values.secrets.tokens.accessToken -}}
{{- else -}}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end -}}
{{/* Server container port */}}
{{- define "twenty.server.containerPort" -}}
{{- .Values.server.service.port | default 3000 -}}
{{- end -}}
{{/* Storage type: prefer top-level storage.type, else legacy server.env.STORAGE_TYPE, else local */}}
{{- define "twenty.storageType" -}}
{{- if .Values.storage.type -}}
{{- .Values.storage.type -}}
{{- else if .Values.server.env.STORAGE_TYPE -}}
{{- .Values.server.env.STORAGE_TYPE -}}
{{- else -}}
local
{{- end -}}
{{- end -}}
{{/* Additional storage env vars (e.g., S3) */}}
{{- define "twenty.storageEnv" -}}
{{- if eq (include "twenty.storageType" .) "s3" -}}
{{- with .Values.storage.s3.bucket }}
- name: STORAGE_S3_NAME
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.region }}
- name: STORAGE_S3_REGION
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.endpoint }}
- name: STORAGE_S3_ENDPOINT
value: {{ . | quote }}
{{- end }}
{{- if and .Values.storage.s3.secretName .Values.storage.s3.accessKeyIdKey }}
- name: STORAGE_S3_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ .Values.storage.s3.secretName | quote }}
key: {{ .Values.storage.s3.accessKeyIdKey | quote }}
{{- else }}
{{- with .Values.storage.s3.accessKeyId }}
- name: STORAGE_S3_ACCESS_KEY_ID
value: {{ . | quote }}
{{- end }}
{{- end }}
{{- if and .Values.storage.s3.secretName .Values.storage.s3.secretAccessKeyKey }}
- name: STORAGE_S3_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.storage.s3.secretName | quote }}
key: {{ .Values.storage.s3.secretAccessKeyKey | quote }}
{{- else }}
{{- with .Values.storage.s3.secretAccessKey }}
- name: STORAGE_S3_SECRET_ACCESS_KEY
value: {{ . | quote }}
{{- end }}
{{- end }}
{{- end -}}
{{- end -}}
@@ -1,62 +0,0 @@
{{- if .Values.db.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-db
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: db
strategy:
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
spec:
volumes:
{{- if .Values.db.internal.persistence.enabled }}
- name: db-data
persistentVolumeClaim:
claimName: {{ if .Values.db.internal.persistence.existingClaim }}{{ .Values.db.internal.persistence.existingClaim }}{{ else }}{{ include "twenty.fullname" . }}-db{{ end }}
{{- end }}
containers:
- name: db
image: {{ .Values.db.internal.image.repository }}:{{ .Values.db.internal.image.tag }}
imagePullPolicy: {{ .Values.db.internal.image.pullPolicy | default "IfNotPresent" }}
env:
- name: PGUSER_SUPERUSER
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-superuser
key: username
- name: PGPASSWORD_SUPERUSER
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-superuser
key: password
- name: SPILO_PROVIDER
value: {{ .Values.db.internal.env.SPILO_PROVIDER | quote }}
- name: ALLOW_NOSSL
value: {{ .Values.db.internal.env.ALLOW_NOSSL | quote }}
ports:
- containerPort: 5432
name: tcp
protocol: TCP
resources:
{{- toYaml .Values.db.internal.resources | nindent 12 }}
{{- if .Values.db.internal.persistence.enabled }}
volumeMounts:
- name: db-data
mountPath: /home/postgres/pgdata
{{- end }}
{{- end }}
@@ -1,57 +0,0 @@
{{- if .Values.redisInternal.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-redis
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: redis
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
containers:
- name: redis
image: {{ .Values.redisInternal.image.repository }}:{{ .Values.redisInternal.image.tag }}
imagePullPolicy: {{ default "IfNotPresent" .Values.redisInternal.image.pullPolicy }}
command:
- redis-stack-server
args:
- "--port"
- {{ .Values.redisInternal.service.port | quote }}
- "--maxmemory-policy"
- "noeviction"
ports:
- containerPort: {{ .Values.redisInternal.service.port }}
name: redis
protocol: TCP
resources:
{{- toYaml .Values.redisInternal.resources | nindent 12 }}
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
{{- if .Values.redisInternal.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
@@ -1,196 +0,0 @@
{{- if .Values.server.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-server
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
replicas: {{ .Values.server.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: server
strategy:
{{- if or .Values.server.dockerDataPersistence.enabled .Values.server.persistence.enabled }}
type: Recreate
{{- else }}
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
{{- end }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
securityContext:
{{- toYaml .Values.securityContext | nindent 8 }}
volumes:
{{- if .Values.server.dockerDataPersistence.enabled }}
- name: docker-data
persistentVolumeClaim:
claimName: {{ if .Values.server.dockerDataPersistence.existingClaim }}{{ .Values.server.dockerDataPersistence.existingClaim }}{{ else }}{{ include "twenty.fullname" . }}-docker-data{{ end }}
{{- end }}
{{- if .Values.server.persistence.enabled }}
- name: server-data
persistentVolumeClaim:
claimName: {{ if .Values.server.persistence.existingClaim }}{{ .Values.server.persistence.existingClaim }}{{ else }}{{ include "twenty.fullname" . }}-server{{ end }}
{{- end }}
initContainers:
- name: wait-for-db
image: postgres:16-alpine
command:
- sh
- -c
- |
until pg_isready -h {{ if .Values.db.enabled }}{{ include "twenty.fullname" . }}-db{{ else }}{{ .Values.db.external.host }}{{ end }} \
-p {{ if .Values.db.enabled }}5432{{ else }}{{ .Values.db.external.port | default 5432 }}{{ end }} \
-U postgres; do
echo "Waiting for database socket..."
sleep 2
done
echo "Database socket is ready!"
{{- if .Values.db.enabled }}
- name: ensure-database-exists
image: postgres:16-alpine
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-superuser
key: password
- name: APP_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-url
key: appPassword
command:
- sh
- -c
- |
DBNAME={{ .Values.db.internal.database | default "twenty" }}
APP_USER={{ .Values.db.internal.appUser | default "twenty_app_user" }}
export PGPASSWORD
export APP_PASSWORD
echo "Creating database ${DBNAME} if it doesn't exist..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -Atc "SELECT 1 FROM pg_database WHERE datname = :'db'" | grep -q 1 || \
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -c 'CREATE DATABASE :"db";'
echo "Creating app user ${APP_USER} if it doesn't exist..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
END IF;
END
$do$;
EOSQL
echo "Creating core schema and granting permissions..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'CREATE SCHEMA IF NOT EXISTS core'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v db="${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON DATABASE :"db" TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON SCHEMA core TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON SCHEMA public TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA core TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA core GRANT ALL ON TABLES TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA core GRANT ALL ON SEQUENCES TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO :"app_user";'
echo "Database ${DBNAME} is ready."
{{- end }}
- name: run-migrations
{{- $img := include "twenty.server.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
command:
- sh
- -c
- >-
npx -y typeorm migration:run -d dist/database/typeorm/core/core.datasource
env:
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-url
key: url
containers:
- name: server
{{- $img := include "twenty.server.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
env:
- name: SERVER_URL
value: {{ include "twenty.serverUrl" . | quote }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-url
key: url
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: SIGN_IN_PREFILLED
value: {{ .Values.server.env.SIGN_IN_PREFILLED | quote }}
- name: STORAGE_TYPE
value: {{ include "twenty.storageType" . | quote }}
- name: ACCESS_TOKEN_EXPIRES_IN
value: {{ .Values.server.env.ACCESS_TOKEN_EXPIRES_IN | quote }}
- name: LOGIN_TOKEN_EXPIRES_IN
value: {{ .Values.server.env.LOGIN_TOKEN_EXPIRES_IN | quote }}
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: {{ include "twenty.secret.tokens.name" . }}
key: accessToken
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- end }}
ports:
- name: http-tcp
containerPort: {{ include "twenty.server.containerPort" . }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: {{ include "twenty.server.containerPort" . }}
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /
port: {{ include "twenty.server.containerPort" . }}
initialDelaySeconds: 40
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 5
resources:
{{- toYaml .Values.server.resources | nindent 12 }}
volumeMounts:
{{- if .Values.server.dockerDataPersistence.enabled }}
- name: docker-data
mountPath: /app/docker-data
{{- end }}
{{- if .Values.server.persistence.enabled }}
- name: server-data
mountPath: /app/packages/twenty-server/.local-storage
{{- end }}
{{- with .Values.server.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.server.stdin }}
stdin: {{ .Values.server.stdin }}
{{- end }}
{{- if .Values.server.tty }}
tty: {{ .Values.server.tty }}
{{- end }}
{{- end }}
@@ -1,77 +0,0 @@
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-worker
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
spec:
replicas: {{ .Values.worker.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: worker
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
spec:
securityContext:
{{- toYaml .Values.securityContext | nindent 8 }}
initContainers:
- name: wait-for-db
image: postgres:16-alpine
command:
- sh
- -c
- |
until pg_isready -h {{ if .Values.db.enabled }}{{ include "twenty.fullname" . }}-db{{ else }}{{ .Values.db.external.host }}{{ end }} \
-p {{ if .Values.db.enabled }}5432{{ else }}{{ .Values.db.external.port | default 5432 }}{{ end }} \
-U postgres; do
echo "Waiting for database socket..."
sleep 2
done
echo "Database socket is ready!"
containers:
- name: worker
{{- $img := include "twenty.worker.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
command:
{{- toYaml .Values.worker.command | nindent 12 }}
env:
- name: SERVER_URL
value: {{ include "twenty.serverUrl" . | quote }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-url
key: url
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: STORAGE_TYPE
value: {{ include "twenty.storageType" . | quote }}
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: {{ include "twenty.secret.tokens.name" . }}
key: accessToken
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
stdin: {{ default true .Values.worker.stdin }}
tty: {{ default true .Values.worker.tty }}
{{- end }}
@@ -1,47 +0,0 @@
{{- if and .Values.server.enabled .Values.server.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "twenty.fullname" . }}
namespace: {{ include "twenty.namespace" . }}
annotations:
{{- if .Values.server.ingress.acme }}
cert-manager.io/cluster-issuer: "letsencrypt-prod"
{{- end }}
{{- with .Values.server.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.server.ingress.className }}
ingressClassName: {{ .Values.server.ingress.className }}
{{- end }}
rules:
{{- range .Values.server.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- if .paths }}
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "twenty.fullname" $ }}-server
port:
name: http-tcp
{{- end }}
{{- else }}
- path: /
pathType: Prefix
backend:
service:
name: {{ include "twenty.fullname" $ }}-server
port:
name: http-tcp
{{- end }}
{{- end }}
{{- if .Values.server.ingress.tls }}
tls:
{{- toYaml .Values.server.ingress.tls | nindent 4 }}
{{- end }}
{{- end }}
@@ -1,20 +0,0 @@
{{- if and .Values.db.enabled .Values.db.internal.persistence.enabled (not .Values.db.internal.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "twenty.fullname" . }}-db
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
spec:
accessModes:
{{ toYaml .Values.db.internal.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.db.internal.persistence.size }}
{{- if .Values.db.internal.persistence.storageClass }}
storageClassName: {{ .Values.db.internal.persistence.storageClass }}
{{- end }}
{{- end }}
@@ -1,20 +0,0 @@
{{- if and .Values.server.enabled .Values.server.dockerDataPersistence.enabled (not .Values.server.dockerDataPersistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "twenty.fullname" . }}-docker-data
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
accessModes:
{{ toYaml .Values.server.dockerDataPersistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.server.dockerDataPersistence.size }}
{{- if .Values.server.dockerDataPersistence.storageClass }}
storageClassName: {{ .Values.server.dockerDataPersistence.storageClass }}
{{- end }}
{{- end }}

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