Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa04cc380 |
@@ -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
|
||||
|
||||
@@ -12,12 +12,7 @@ alwaysApply: true
|
||||
npx nx run twenty-front:build
|
||||
npx nx run twenty-server:test
|
||||
|
||||
# Lint diff with main (recommended - much faster!)
|
||||
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
|
||||
npx nx lint:diff-with-main twenty-server
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
|
||||
|
||||
# Run target for all projects (slower)
|
||||
# Run target for all projects
|
||||
npx nx run-many --target=build --all
|
||||
npx nx run-many --target=test --projects=twenty-front,twenty-server
|
||||
|
||||
@@ -48,18 +43,12 @@ npx nx g @nx/react:component my-component
|
||||
}
|
||||
```
|
||||
|
||||
## Linting Strategy
|
||||
For faster development, always prefer linting only changed files:
|
||||
- Use `npx nx lint:diff-with-main <project>` to lint only files changed vs main branch
|
||||
- Use `--configuration=fix` to auto-fix issues in changed files
|
||||
- Only use `npx nx lint <project>` when you need to lint the entire project
|
||||
|
||||
## Dependency Graph
|
||||
```bash
|
||||
# View project dependencies
|
||||
npx nx graph
|
||||
|
||||
# Check what's affected by changes (runs target on affected projects)
|
||||
# Check what's affected by changes
|
||||
npx nx affected --target=test
|
||||
npx nx affected --target=build --base=main
|
||||
```
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#
|
||||
# Crowdin CLI configuration for App translations (twenty-front, twenty-server, twenty-emails)
|
||||
# Project ID: 1
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"preserve_hierarchy": true
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Documentation translations
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"project_id": 2
|
||||
"preserve_hierarchy": true
|
||||
"base_url": "https://twenty.api.crowdin.com"
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# MDX documentation files - user-guide
|
||||
# Using md type to preserve JSX component structure
|
||||
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
|
||||
#
|
||||
"source": "packages/twenty-docs/user-guide/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - developers
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/developers/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - twenty-ui
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# Navigation labels template - translated into per-locale navigation.json
|
||||
#
|
||||
"source": "packages/twenty-docs/navigation/navigation.template.json",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
|
||||
}
|
||||
]
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ runs:
|
||||
id: cache-primary-key-builder
|
||||
shell: bash
|
||||
run: |
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=v3-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Restore cache
|
||||
uses: actions/cache/restore@v4
|
||||
id: restore-cache
|
||||
@@ -32,4 +32,4 @@ runs:
|
||||
.nx
|
||||
node_modules/.cache
|
||||
packages/*/node_modules/.cache
|
||||
${{ inputs.additional-paths }}
|
||||
${{ inputs.additional-paths }}
|
||||
@@ -63,9 +63,9 @@ jobs:
|
||||
- 8123:8123
|
||||
- 9000:9000
|
||||
options: >-
|
||||
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
@@ -78,12 +78,12 @@ jobs:
|
||||
id: merge_attempt
|
||||
run: |
|
||||
echo "Attempting to merge main into current branch..."
|
||||
|
||||
|
||||
git fetch origin main
|
||||
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
|
||||
|
||||
if git merge origin/main --no-edit; then
|
||||
echo "✅ Successfully merged main into current branch"
|
||||
echo "merged=true" >> $GITHUB_OUTPUT
|
||||
@@ -91,16 +91,16 @@ jobs:
|
||||
else
|
||||
echo "❌ Merge failed due to conflicts"
|
||||
echo "⚠️ Falling back to comparing current branch against main without merge"
|
||||
|
||||
|
||||
# Abort the failed merge
|
||||
git merge --abort
|
||||
|
||||
|
||||
echo "merged=false" >> $GITHUB_OUTPUT
|
||||
echo "BRANCH_STATE=conflicts" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build shared dependencies
|
||||
run: |
|
||||
@@ -128,22 +128,22 @@ jobs:
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
|
||||
echo "" >> "$env_file"
|
||||
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/current_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.CURRENT_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
@@ -166,19 +166,19 @@ jobs:
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Current branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
|
||||
echo "Current branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for current branch server to start"
|
||||
echo "Current server log:"
|
||||
@@ -188,15 +188,15 @@ jobs:
|
||||
|
||||
- name: Download GraphQL and REST responses from current branch
|
||||
run: |
|
||||
# Read admin token from shared test tokens file (single source of truth)
|
||||
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
|
||||
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
|
||||
echo "Downloading GraphQL schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
-o current-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
echo "Downloading GraphQL metadata schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -214,32 +214,32 @@ jobs:
|
||||
-o current-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
# Download current branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from current server..."
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
# Verify the downloads
|
||||
echo "Current branch files downloaded:"
|
||||
ls -la current-*
|
||||
|
||||
|
||||
|
||||
- name: Preserve current branch files
|
||||
run: |
|
||||
# Create a temp directory to store current branch files
|
||||
mkdir -p /tmp/current-branch-files
|
||||
|
||||
|
||||
# Move current branch files to temp directory
|
||||
mv current-* /tmp/current-branch-files/ 2>/dev/null || echo "No current-* files to preserve"
|
||||
|
||||
|
||||
echo "Preserved current branch files for later restoration"
|
||||
|
||||
- name: Stop current branch server
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
rm -rf node_modules packages/*/node_modules packages/*/dist dist .nx/cache
|
||||
|
||||
- name: Install dependencies for main branch
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build main branch dependencies
|
||||
run: |
|
||||
@@ -281,22 +281,22 @@ jobs:
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
|
||||
echo "" >> "$env_file"
|
||||
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
@@ -319,19 +319,19 @@ jobs:
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Main branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
|
||||
echo "Main branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for main branch server to start"
|
||||
echo "Main server log:"
|
||||
@@ -341,15 +341,15 @@ jobs:
|
||||
|
||||
- name: Download GraphQL and REST responses from main branch
|
||||
run: |
|
||||
# Read admin token from shared test tokens file (single source of truth)
|
||||
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
|
||||
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
|
||||
echo "Downloading GraphQL schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -358,7 +358,7 @@ jobs:
|
||||
-o main-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
echo "Downloading GraphQL metadata schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -367,33 +367,33 @@ jobs:
|
||||
-o main-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
# Download main branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from main server..."
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
# Verify the downloads
|
||||
echo "Main branch files downloaded:"
|
||||
ls -la main-*
|
||||
|
||||
|
||||
|
||||
- name: Restore current branch files
|
||||
run: |
|
||||
# Move current branch files back to working directory
|
||||
mv /tmp/current-branch-files/* . 2>/dev/null || echo "No files to restore"
|
||||
|
||||
|
||||
# Verify all files are present
|
||||
echo "All API files restored:"
|
||||
ls -la current-* main-* 2>/dev/null || echo "Some files may be missing"
|
||||
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf /tmp/current-branch-files
|
||||
|
||||
@@ -406,9 +406,9 @@ jobs:
|
||||
run: |
|
||||
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
|
||||
npm install -g @graphql-inspector/cli
|
||||
|
||||
|
||||
echo "=== GENERATING GRAPHQL DIFF REPORTS ==="
|
||||
|
||||
|
||||
# Check if GraphQL schema has changes
|
||||
echo "Checking GraphQL schema for changes..."
|
||||
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
echo "\`\`\`" >> graphql-schema-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
|
||||
# Check if GraphQL metadata schema has changes
|
||||
echo "Checking GraphQL metadata schema for changes..."
|
||||
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
|
||||
@@ -444,7 +444,7 @@ jobs:
|
||||
echo "\`\`\`" >> graphql-metadata-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
|
||||
# Show summary
|
||||
echo "Generated diff files:"
|
||||
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
|
||||
@@ -452,32 +452,32 @@ jobs:
|
||||
- name: Check REST API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
|
||||
|
||||
|
||||
# Use the Java-based openapi-diff via Docker
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-api-diff.json \
|
||||
/specs/main-rest-api.json /specs/current-rest-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-api-diff.json" ] && jq empty rest-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-api-diff.json)
|
||||
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST API"
|
||||
|
||||
|
||||
# Generate breaking changes report
|
||||
echo "# REST API Breaking Changes" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
@@ -493,7 +493,7 @@ jobs:
|
||||
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
|
||||
else "" end
|
||||
' rest-api-diff.json >> rest-api-diff.md
|
||||
|
||||
|
||||
elif [ "$different" = "true" ]; then
|
||||
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed) - no PR comment will be posted"
|
||||
# Don't create markdown file for non-breaking changes to avoid PR comments
|
||||
@@ -503,7 +503,7 @@ jobs:
|
||||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the files"
|
||||
|
||||
|
||||
echo "# REST API Analysis Error" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST API changes**" >> rest-api-diff.md
|
||||
@@ -512,7 +512,7 @@ jobs:
|
||||
echo "\`\`\`" >> rest-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-api.json /specs/current-rest-api.json 2>&1 >> rest-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-api-diff.md
|
||||
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST API analysis tool error - continuing workflow"
|
||||
fi
|
||||
@@ -520,33 +520,33 @@ jobs:
|
||||
- name: Check REST Metadata API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
|
||||
|
||||
|
||||
# Use the Java-based openapi-diff for metadata API as well
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-metadata-api-diff.json \
|
||||
/specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-metadata-api-diff.json" ] && jq empty rest-metadata-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-metadata-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-metadata-api-diff.json)
|
||||
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST Metadata API"
|
||||
|
||||
|
||||
# Generate breaking changes report (only for breaking changes)
|
||||
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
|
||||
@@ -570,7 +570,7 @@ jobs:
|
||||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the metadata API files"
|
||||
|
||||
|
||||
echo "# REST Metadata API Analysis Error" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST Metadata API changes**" >> rest-metadata-api-diff.md
|
||||
@@ -579,7 +579,7 @@ jobs:
|
||||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json 2>&1 >> rest-metadata-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
|
||||
fi
|
||||
@@ -592,7 +592,7 @@ jobs:
|
||||
const fs = require('fs');
|
||||
let hasChanges = false;
|
||||
let comment = '';
|
||||
|
||||
|
||||
try {
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
@@ -604,7 +604,7 @@ jobs:
|
||||
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.trim()) {
|
||||
@@ -615,7 +615,7 @@ jobs:
|
||||
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.trim()) {
|
||||
@@ -626,7 +626,7 @@ jobs:
|
||||
comment += restDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.trim()) {
|
||||
@@ -637,37 +637,37 @@ jobs:
|
||||
comment += metadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Only post comment if there are changes
|
||||
if (hasChanges) {
|
||||
// Add branch state information only if there were conflicts
|
||||
const branchState = process.env.BRANCH_STATE || 'unknown';
|
||||
let branchStateNote = '';
|
||||
|
||||
|
||||
if (branchState === 'conflicts') {
|
||||
branchStateNote = '\n\n⚠️ **Note**: Could not merge with `main` due to conflicts. This comparison shows changes between the current branch and `main` as separate states.\n';
|
||||
}
|
||||
// Check if there are any breaking changes detected
|
||||
let hasBreakingChanges = false;
|
||||
let breakingChangeNote = '';
|
||||
|
||||
|
||||
// Check for breaking changes in any of the diff files
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
|
||||
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
|
||||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
|
||||
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
|
||||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also check GraphQL changes for breaking changes indicators
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
@@ -675,18 +675,18 @@ jobs:
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check PR title for "breaking"
|
||||
const prTitle = ${{ toJSON(github.event.pull_request.title) }};
|
||||
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
|
||||
|
||||
|
||||
if (hasBreakingChanges) {
|
||||
if (titleContainsBreaking) {
|
||||
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
|
||||
@@ -702,20 +702,20 @@ jobs:
|
||||
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const commentBody = COMMENT_MARKER + '\n' + comment + branchStateNote + '\n⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
|
||||
|
||||
|
||||
// Get all comments to find existing API changes comment
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
|
||||
// Find our existing comment
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
@@ -737,17 +737,17 @@ jobs:
|
||||
}
|
||||
} else {
|
||||
console.log('No API changes detected - skipping PR comment');
|
||||
|
||||
|
||||
// Check if there's an existing comment to remove
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
@@ -780,7 +780,7 @@ jobs:
|
||||
/tmp/main-server.log
|
||||
/tmp/current-server.log
|
||||
*-api.json
|
||||
*-schema-introspection.json
|
||||
*-schema-introspection.json
|
||||
*-diff.md
|
||||
*-diff.json
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Docs / Lint English MDX files
|
||||
run: npx eslint "packages/twenty-docs/{developers,user-guide,twenty-ui,getting-started,snippets}/**/*.mdx" --max-warnings 0
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
name: CI E2E Playwright Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/**
|
||||
playwright.config.ts
|
||||
.github/workflows/ci-e2e.yaml
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true' && ( github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# https://github.com/actions/runner-images/issues/70#issuecomment-589562148
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: "true"
|
||||
SPILO_PROVIDER: "local"
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Check system resources
|
||||
run: |
|
||||
echo "Available memory:"
|
||||
free -h
|
||||
echo "Available disk space:"
|
||||
df -h
|
||||
echo "CPU info:"
|
||||
lscpu
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx nx setup twenty-e2e-testing
|
||||
|
||||
- name: Setup environment files
|
||||
run: |
|
||||
cp packages/twenty-front/.env.example packages/twenty-front/.env
|
||||
npx nx reset:env twenty-server
|
||||
|
||||
- name: Build frontend
|
||||
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
|
||||
|
||||
- name: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Start frontend
|
||||
run: |
|
||||
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
|
||||
echo "Waiting for frontend to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
|
||||
|
||||
- name: Start worker
|
||||
run: |
|
||||
npx nx run twenty-server:worker &
|
||||
echo "Worker started"
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npx nx test twenty-e2e-testing
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: packages/twenty-e2e-testing/run_results/
|
||||
retention-days: 30
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: packages/twenty-e2e-testing/playwright-report/
|
||||
retention-days: 30
|
||||
ci-e2e-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Build twenty-emails
|
||||
run: npx nx build twenty-emails
|
||||
- name: Run email tests
|
||||
@@ -40,17 +40,17 @@ jobs:
|
||||
# Start the email server in the background
|
||||
npx nx run twenty-emails:start &
|
||||
SERVER_PID=$!
|
||||
|
||||
|
||||
# Wait for server to start
|
||||
sleep 20
|
||||
|
||||
|
||||
# Check if server is running
|
||||
if ! curl -s http://localhost:4001/preview/test.email > /dev/null; then
|
||||
echo "Email server failed to start"
|
||||
kill $SERVER_PID
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Kill the server
|
||||
kill $SERVER_PID
|
||||
ci-emails-status-check:
|
||||
@@ -61,4 +61,4 @@ jobs:
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
run: exit 1
|
||||
+21
-174
@@ -1,9 +1,11 @@
|
||||
name: CI Front and E2E
|
||||
name: CI Front
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
merge_group:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,9 +15,8 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v3-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
@@ -27,13 +28,6 @@ jobs:
|
||||
packages/twenty-front/**
|
||||
packages/twenty-ui/**
|
||||
packages/twenty-shared/**
|
||||
changed-files-check-e2e:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/**
|
||||
playwright.config.ts
|
||||
.github/workflows/ci-front.yaml
|
||||
front-sb-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
@@ -51,17 +45,15 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Diagnostic disk space issue
|
||||
run: df -h
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Front / Clean storybook-static
|
||||
run: rm -rf packages/twenty-front/storybook-static
|
||||
- name: Front / Build storybook
|
||||
run: npx nx storybook:build twenty-front
|
||||
- name: Save storybook build cache
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
|
||||
front-sb-test:
|
||||
@@ -82,11 +74,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Install Playwright
|
||||
run: cd packages/twenty-front && npx playwright install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
- name: Front / Write .env
|
||||
@@ -115,7 +107,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
|
||||
@@ -129,7 +121,7 @@ jobs:
|
||||
run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
|
||||
front-chromatic-deployment:
|
||||
timeout-minutes: 30
|
||||
if: false
|
||||
if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push'
|
||||
needs: front-sb-build
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
@@ -140,11 +132,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
|
||||
- name: Front / Write .env
|
||||
run: |
|
||||
cd packages/twenty-front
|
||||
@@ -173,162 +165,26 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore ${{ matrix.task }} cache
|
||||
id: restore-task-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.TASK_CACHE_KEY }}
|
||||
- name: Reset .env
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: reset:env
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
- name: Save ${{ matrix.task }} cache
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-task-cache.outputs.cache-primary-key }}
|
||||
front-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Build frontend
|
||||
run: npx nx build twenty-front
|
||||
- name: Upload frontend build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: packages/twenty-front/build
|
||||
retention-days: 1
|
||||
e2e-test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check-e2e, front-build]
|
||||
if: |
|
||||
always() &&
|
||||
needs.changed-files-check-e2e.outputs.any_changed == 'true' &&
|
||||
(needs.front-build.result == 'success' || needs.front-build.result == 'skipped') &&
|
||||
(github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: "true"
|
||||
SPILO_PROVIDER: "local"
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Check system resources
|
||||
run: |
|
||||
echo "Available memory:"
|
||||
free -h
|
||||
echo "Available disk space:"
|
||||
df -h
|
||||
echo "CPU info:"
|
||||
lscpu
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx nx setup twenty-e2e-testing
|
||||
|
||||
- name: Setup environment files
|
||||
run: |
|
||||
cp packages/twenty-front/.env.example packages/twenty-front/.env
|
||||
npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Download frontend build artifact
|
||||
if: needs.front-build.result == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: packages/twenty-front/build
|
||||
|
||||
- name: Build frontend (if not available from front-build)
|
||||
if: needs.front-build.result == 'skipped'
|
||||
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
|
||||
|
||||
- name: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Start frontend
|
||||
run: |
|
||||
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
|
||||
echo "Waiting for frontend to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
|
||||
|
||||
- name: Start worker
|
||||
run: |
|
||||
npx nx run twenty-server:worker &
|
||||
echo "Worker started"
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npx nx test twenty-e2e-testing
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: packages/twenty-e2e-testing/run_results/
|
||||
retention-days: 30
|
||||
|
||||
ci-front-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
@@ -337,7 +193,7 @@ jobs:
|
||||
[
|
||||
changed-files-check,
|
||||
front-task,
|
||||
front-build,
|
||||
front-chromatic-deployment,
|
||||
merge-reports-and-check-coverage,
|
||||
front-sb-test,
|
||||
front-sb-build,
|
||||
@@ -346,12 +202,3 @@ jobs:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
ci-e2e-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check-e2e, e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
@@ -56,4 +56,4 @@ jobs:
|
||||
title: Release v${{ steps.sanitize.outputs.version }}
|
||||
labels: |
|
||||
release
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
@@ -1,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: |
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
name: CI Server
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
merge_group:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -57,16 +59,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
id: restore-server-setup-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
- name: Server / Run lint & typecheck
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: lint,typecheck
|
||||
@@ -138,7 +140,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
- name: Save server setup
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
|
||||
server-test:
|
||||
@@ -151,13 +153,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Run Tests
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: test
|
||||
@@ -169,7 +171,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
@@ -207,14 +209,14 @@ jobs:
|
||||
ANALYTICS_ENABLED: true
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
SHARD_COUNTER: 8
|
||||
SHARD_COUNTER: 5
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Update .env.test for integrations tests
|
||||
run: |
|
||||
echo "" >> .env.test
|
||||
@@ -224,7 +226,7 @@ jobs:
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Restore server setup
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Build
|
||||
@@ -241,7 +243,7 @@ jobs:
|
||||
- name: Run ClickHouse seeds
|
||||
run: npx nx clickhouse:seed twenty-server
|
||||
- name: Server / Run Integration Tests
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: 'test:integration'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
name: CI Shared
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -36,9 +38,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: CI Docker Compose
|
||||
name: 'Test Docker Compose'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -30,12 +30,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Utils / Run Danger.js
|
||||
run: cd packages/twenty-utils && npx nx danger:ci
|
||||
env:
|
||||
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
|
||||
|
||||
|
||||
congratulate:
|
||||
timeout-minutes: 3
|
||||
runs-on: ubuntu-latest
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run congratulate-dangerfile.js
|
||||
run: cd packages/twenty-utils && npx nx danger:congratulate
|
||||
env:
|
||||
|
||||
@@ -4,7 +4,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -45,7 +47,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Server / Create DB
|
||||
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
|
||||
@@ -24,7 +24,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/twenty-docs/**'
|
||||
- '.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
|
||||
|
||||
@@ -7,12 +7,11 @@ on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ['main']
|
||||
branches: ['main', 'docs-localized-navigation']
|
||||
paths:
|
||||
- 'packages/twenty-docs/**/*.mdx'
|
||||
- '!packages/twenty-docs/l/**'
|
||||
- 'packages/twenty-docs/navigation/navigation.template.json'
|
||||
- '.github/crowdin-docs.yml'
|
||||
- '!packages/twenty-docs/fr/**'
|
||||
- 'crowdin.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@@ -29,8 +28,28 @@ jobs:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Generate navigation template for Crowdin
|
||||
run: yarn docs:generate-navigation-template
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Generate navigation template for Crowdin
|
||||
run: yarn docs:generate-navigation-template
|
||||
@@ -41,11 +60,9 @@ jobs:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
localization_branch_name: i18n-docs
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: '.github/crowdin-docs.yml'
|
||||
env:
|
||||
# Docs translations project
|
||||
CROWDIN_PROJECT_ID: '2'
|
||||
CROWDIN_PROJECT_ID: 1
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
@@ -85,14 +85,12 @@ jobs:
|
||||
push_sources: false
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
push_translations: false
|
||||
push_translations: true
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
config: '.github/crowdin-app.yml'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# App translations project
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -101,12 +99,6 @@ jobs:
|
||||
- name: Fix file permissions
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
# Fix encoding issues (escaped Unicode like \u62db -> 招) and push fixes back to Crowdin
|
||||
- name: Fix translation encoding and sync to Crowdin
|
||||
run: npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Compile translations
|
||||
id: compile_translations
|
||||
# Because we have set English as a fallback locale, this condition does not work anymore
|
||||
@@ -116,6 +108,8 @@ jobs:
|
||||
npx nx run twenty-emails:lingui:compile
|
||||
npx nx run twenty-front:lingui:compile
|
||||
git status
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add .
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile translations"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
@@ -87,10 +87,11 @@ jobs:
|
||||
download_translations: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: '.github/crowdin-app.yml'
|
||||
env:
|
||||
# App translations project
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: 1
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: Create a pull request
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# Weekly translation QA report using Crowdin's native QA checks
|
||||
|
||||
name: 'Weekly Translation QA Report'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Every Monday at 9am UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qa_report:
|
||||
name: Generate QA Report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Generate QA report from Crowdin
|
||||
id: generate_report
|
||||
run: |
|
||||
npx ts-node packages/twenty-utils/translation-qa-report.ts || true
|
||||
if [ -f TRANSLATION_QA_REPORT.md ]; then
|
||||
echo "report_generated=true" >> $GITHUB_OUTPUT
|
||||
# Count critical issues (exclude spellcheck)
|
||||
CRITICAL=$(grep -oP '⚠️\s+\K\d+' TRANSLATION_QA_REPORT.md 2>/dev/null || echo "0")
|
||||
echo "critical_issues=$CRITICAL" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "report_generated=false" >> $GITHUB_OUTPUT
|
||||
echo "critical_issues=0" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Create QA branch and commit report
|
||||
if: steps.generate_report.outputs.report_generated == 'true'
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
|
||||
BRANCH_NAME="i18n-qa-report-$(date +%Y-%m-%d)"
|
||||
git checkout -B $BRANCH_NAME
|
||||
|
||||
git add TRANSLATION_QA_REPORT.md
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "docs: weekly translation QA report"
|
||||
git push origin HEAD:$BRANCH_NAME --force
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
else
|
||||
echo "No changes to commit"
|
||||
echo "BRANCH_NAME=" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.generate_report.outputs.report_generated == 'true' && env.BRANCH_NAME != ''
|
||||
run: |
|
||||
CRITICAL="${{ steps.generate_report.outputs.critical_issues }}"
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
## Weekly Translation QA Report
|
||||
|
||||
**Critical issues (excluding spellcheck): $CRITICAL**
|
||||
|
||||
📊 **View in Crowdin**: https://twenty.crowdin.com/u/projects/1/all?filter=qa-issue
|
||||
|
||||
### For AI-Assisted Fixing
|
||||
|
||||
Open this PR in Cursor and say:
|
||||
|
||||
> "Fix the translation QA issues using the Crowdin API"
|
||||
|
||||
The AI can help fix:
|
||||
- ✅ Variables mismatch (missing/wrong placeholders)
|
||||
- ✅ Escaped Unicode sequences
|
||||
- ⚠️ Tags mismatch
|
||||
- ⚠️ Empty translations
|
||||
|
||||
### Available Scripts
|
||||
|
||||
\`\`\`bash
|
||||
# View QA report
|
||||
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/translation-qa-report.ts
|
||||
|
||||
# Fix encoding issues automatically
|
||||
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
*Close without merging after issues are addressed*
|
||||
EOF
|
||||
)
|
||||
|
||||
EXISTING_PR=$(gh pr list --head $BRANCH_NAME --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
gh pr edit $EXISTING_PR --body "$BODY"
|
||||
else
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head $BRANCH_NAME \
|
||||
--title "i18n: Translation QA Report ($CRITICAL critical issues)" \
|
||||
--body "$BODY" || true
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -48,4 +48,3 @@ dump.rdb
|
||||
|
||||
mcp.json
|
||||
/.junie/
|
||||
TRANSLATION_QA_REPORT.md
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Add files here to ignore them from prettier formatting
|
||||
/dist
|
||||
/coverage
|
||||
/.nx/cache
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
Vendored
-1
@@ -31,7 +31,6 @@
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"javascript.format.enable": false,
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.format.enable": false,
|
||||
"cSpell.enableFiletypes": [
|
||||
"!javascript",
|
||||
|
||||
@@ -36,15 +36,10 @@ When testing the UI end to end, click on "Continue with Email" and use the prefi
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Linting (diff with main - fastest)
|
||||
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
|
||||
npx nx lint:diff-with-main twenty-server # Lint only files changed vs main
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix files changed vs main
|
||||
|
||||
# Linting (full project)
|
||||
npx nx lint twenty-front # Lint all files in frontend
|
||||
npx nx lint twenty-server # Lint all files in backend
|
||||
npx nx lint twenty-front --fix # Auto-fix all linting issues
|
||||
# Linting
|
||||
npx nx lint twenty-front # Frontend linting
|
||||
npx nx lint twenty-server # Backend linting
|
||||
npx nx lint twenty-front --fix # Auto-fix linting issues
|
||||
|
||||
# Type checking
|
||||
npx nx typecheck twenty-front
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
DOCKER_NETWORK=twenty_network
|
||||
|
||||
ensure-docker-network:
|
||||
docker network inspect $(DOCKER_NETWORK) >/dev/null 2>&1 || docker network create $(DOCKER_NETWORK)
|
||||
|
||||
postgres-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) \
|
||||
--name twenty_pg \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e ALLOW_NOSSL=true \
|
||||
-v twenty_db_data:/var/lib/postgresql/data \
|
||||
-p 5432:5432 \
|
||||
postgres:16
|
||||
@echo "Waiting for PostgreSQL to be ready..."
|
||||
@until docker exec twenty_pg psql -U postgres -d postgres \
|
||||
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
|
||||
sleep 1; \
|
||||
done
|
||||
docker exec twenty_pg psql -U postgres -d postgres \
|
||||
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
|
||||
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
|
||||
|
||||
redis-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
|
||||
|
||||
clickhouse-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest \
|
||||
|
||||
grafana-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) \
|
||||
--name twenty_grafana \
|
||||
-p 4000:3000 \
|
||||
-e GF_SECURITY_ADMIN_USER=admin \
|
||||
-e GF_SECURITY_ADMIN_PASSWORD=admin \
|
||||
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
|
||||
-v $(PWD)/packages/twenty-docker/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources \
|
||||
grafana/grafana-oss:latest
|
||||
|
||||
opentelemetry-collector-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) \
|
||||
--name twenty_otlp_collector \
|
||||
-p 4317:4317 \
|
||||
-p 4318:4318 \
|
||||
-p 13133:13133 \
|
||||
-v $(PWD)/packages/twenty-docker/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml \
|
||||
otel/opentelemetry-collector-contrib:latest \
|
||||
--config /etc/otel-collector-config.yaml
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# Basic Crowdin CLI configuration
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
# See https://support.crowdin.com/developer/configuration-file/ for all available options
|
||||
#
|
||||
|
||||
#
|
||||
# Defines whether to preserve the original directory structure in the Crowdin project
|
||||
# Recommended to set to true
|
||||
#
|
||||
"preserve_hierarchy": true
|
||||
|
||||
#
|
||||
# Files configuration.
|
||||
# See https://support.crowdin.com/developer/configuration-file/ for all available options
|
||||
#
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter
|
||||
# e.g. "/resources/en/*.json"
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files filter
|
||||
# e.g. "/resources/%two_letters_code%/%original_file_name%"
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - user-guide
|
||||
# Using md type to preserve JSX component structure
|
||||
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
|
||||
#
|
||||
"source": "packages/twenty-docs/user-guide/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - developers
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/developers/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - twenty-ui
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# Navigation labels template - translated into per-locale navigation.json
|
||||
#
|
||||
"source": "packages/twenty-docs/navigation/navigation.template.json",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
|
||||
}
|
||||
]
|
||||
+6
-2
@@ -75,6 +75,10 @@ export default [
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -137,10 +141,10 @@ export default [
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowInterfaces: 'with-single-extends',
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import js from '@eslint/js';
|
||||
import nxPlugin from '@nx/eslint-plugin';
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import linguiPlugin from 'eslint-plugin-lingui';
|
||||
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
|
||||
import prettierPlugin from 'eslint-plugin-prettier';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
|
||||
import unicornPlugin from 'eslint-plugin-unicorn';
|
||||
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
|
||||
import jsoncParser from 'jsonc-eslint-parser';
|
||||
|
||||
export default [
|
||||
// Base JavaScript configuration
|
||||
js.configs.recommended,
|
||||
|
||||
// Lingui recommended rules
|
||||
linguiPlugin.configs['flat/recommended'],
|
||||
|
||||
// Base configuration for all files
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
'react': reactPlugin,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
'react-refresh': reactRefreshPlugin,
|
||||
'prettier': prettierPlugin,
|
||||
'lingui': linguiPlugin,
|
||||
'@nx': nxPlugin,
|
||||
'prefer-arrow': preferArrowPlugin,
|
||||
'import': importPlugin,
|
||||
'unused-imports': unusedImportsPlugin,
|
||||
'unicorn': unicornPlugin,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// General rules
|
||||
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
|
||||
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
|
||||
'no-control-regex': 0,
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// Nx rules
|
||||
'@nx/enforce-module-boundaries': [
|
||||
'error',
|
||||
{
|
||||
enforceBuildableLibDependency: true,
|
||||
allow: [],
|
||||
depConstraints: [
|
||||
{
|
||||
sourceTag: 'scope:shared',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:backend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// Import rules
|
||||
'import/no-relative-packages': 'error',
|
||||
'import/no-useless-path-segments': 'error',
|
||||
'import/no-duplicates': ['error', { considerQueryString: true }],
|
||||
|
||||
// Prefer arrow functions
|
||||
'prefer-arrow/prefer-arrow-functions': [
|
||||
'error',
|
||||
{
|
||||
disallowPrototype: true,
|
||||
singleReturnOnly: false,
|
||||
classPropertiesAllowed: false,
|
||||
},
|
||||
],
|
||||
|
||||
// Unused imports
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
// React rules
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react/jsx-key': 'off',
|
||||
'react/display-name': 'off',
|
||||
'react/jsx-uses-react': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/jsx-no-useless-fragment': 'off',
|
||||
'react/jsx-props-no-spreading': [
|
||||
'error',
|
||||
{
|
||||
explicitSpread: 'ignore',
|
||||
},
|
||||
],
|
||||
|
||||
// React hooks rules
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: 'useRecoilCallback',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// TypeScript specific configuration
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
// Note: project path should be specified by each package individually
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
},
|
||||
rules: {
|
||||
// Import restrictions
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@tabler/icons-react'],
|
||||
message: 'Please import icons from `twenty-ui`',
|
||||
},
|
||||
{
|
||||
group: ['react-hotkeys-web-hook'],
|
||||
importNames: ['useHotkeys'],
|
||||
message: 'Please use the custom wrapper: `useScopedHotkeys` from `twenty-ui`',
|
||||
},
|
||||
{
|
||||
group: ['lodash'],
|
||||
message: "Please use the standalone lodash package (for instance: `import groupBy from 'lodash.groupby'` instead of `import { groupBy } from 'lodash'`)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// TypeScript rules
|
||||
'no-redeclare': 'off', // Turn off base rule for TypeScript
|
||||
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
|
||||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports'
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
// Custom workspace rules
|
||||
'@nx/workspace-effect-components': 'error',
|
||||
'@nx/workspace-no-hardcoded-colors': 'error',
|
||||
'@nx/workspace-matching-state-variable': 'error',
|
||||
'@nx/workspace-sort-css-properties-alphabetically': 'error',
|
||||
'@nx/workspace-styled-components-prefixed-with-styled': 'error',
|
||||
'@nx/workspace-no-state-useref': 'error',
|
||||
'@nx/workspace-component-props-naming': 'error',
|
||||
'@nx/workspace-explicit-boolean-predicates-in-if': 'error',
|
||||
'@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error',
|
||||
'@nx/workspace-useRecoilCallback-has-dependency-array': 'error',
|
||||
'@nx/workspace-no-navigate-prefer-link': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// Storybook files
|
||||
{
|
||||
files: ['*.stories.@(ts|tsx|js|jsx)'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// JavaScript specific configuration
|
||||
{
|
||||
files: ['*.{js,jsx}'],
|
||||
rules: {
|
||||
// JavaScript-specific rules if needed
|
||||
},
|
||||
},
|
||||
|
||||
// Constants files
|
||||
{
|
||||
files: ['**/constants/*.ts', '**/*.constants.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['UPPER_CASE'],
|
||||
},
|
||||
],
|
||||
'unicorn/filename-case': [
|
||||
'warn',
|
||||
{
|
||||
cases: {
|
||||
pascalCase: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
|
||||
// Test files
|
||||
{
|
||||
files: [
|
||||
'*.test.@(ts|tsx|js|jsx)',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
jest: true,
|
||||
describe: true,
|
||||
it: true,
|
||||
expect: true,
|
||||
beforeEach: true,
|
||||
afterEach: true,
|
||||
beforeAll: true,
|
||||
afterAll: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Constants files
|
||||
{
|
||||
files: ['**/*.constants.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['UPPER_CASE'],
|
||||
},
|
||||
],
|
||||
'unicorn/filename-case': [
|
||||
'warn',
|
||||
{
|
||||
cases: {
|
||||
pascalCase: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
|
||||
// JSON files
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
languageOptions: {
|
||||
parser: jsoncParser,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getJestProjects } from '@nx/jest';
|
||||
|
||||
export default {
|
||||
projects: getJestProjects(),
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
const nxPreset = require('@nx/jest/preset').default;
|
||||
|
||||
module.exports = { ...nxPreset };
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
command -v node >/dev/null 2>&1 || { echo >&2 "Nx requires NodeJS to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
|
||||
command -v npm >/dev/null 2>&1 || { echo >&2 "Nx requires npm to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
|
||||
path_to_root=$(dirname $BASH_SOURCE)
|
||||
node $path_to_root/.nx/nxw.js $@
|
||||
@@ -4,9 +4,7 @@
|
||||
"libsDir": "packages"
|
||||
},
|
||||
"namedInputs": {
|
||||
"default": [
|
||||
"{projectRoot}/**/*"
|
||||
],
|
||||
"default": ["{projectRoot}/**/*"],
|
||||
"excludeStories": [
|
||||
"default",
|
||||
"!{projectRoot}/.storybook/*",
|
||||
@@ -34,26 +32,17 @@
|
||||
"targetDefaults": {
|
||||
"build": {
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"^production",
|
||||
"production"
|
||||
],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"inputs": ["^production", "production"],
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"start": {
|
||||
"cache": false,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+277
@@ -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
-1
@@ -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);
|
||||
|
||||
+29
-42
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+454
@@ -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
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+500
@@ -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
Binary file not shown.
+942
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],
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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'",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user