Compare commits

..
Author SHA1 Message Date
Thomas Trompette 970d2a1943 Build base objects + empty endpoint 2026-02-24 17:05:23 +01:00
Thomas Trompette ec4fd104e4 Add call recording app 2026-02-24 16:18:23 +01:00
5225 changed files with 98026 additions and 193437 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ This directory contains Twenty's development guidelines and best practices in th
### React Development
- **react-general-guidelines.mdc** - Core React development principles (Auto-attached to React files)
- **react-state-management.mdc** - State management approaches with Jotai (Auto-attached to state files)
- **react-state-management.mdc** - State management approaches with Recoil (Auto-attached to state files)
### Testing & Quality
- **testing-guidelines.mdc** - Testing strategies and best practices (Auto-attached to test files)
+1 -1
View File
@@ -7,7 +7,7 @@ alwaysApply: true
# Twenty Architecture
## Tech Stack
- **Frontend**: React 18, TypeScript, Jotai, Styled Components, Vite
- **Frontend**: React 18, TypeScript, Recoil, Styled Components, Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL
- **Monorepo**: Nx workspace with yarn
+12 -33
View File
@@ -4,20 +4,16 @@ alwaysApply: false
---
# React State Management
## Jotai Patterns
## Recoil Patterns
```typescript
// ✅ Atoms for primitive state (use createAtomState for keyed state with optional persistence)
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const currentUserState = createAtomState<User | null>({
// ✅ Atoms for primitive state
export const currentUserState = atom<User | null>({
key: 'currentUserState',
defaultValue: null,
default: null,
});
// ✅ Derived atoms for computed state (use createAtomSelector)
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const userDisplayNameSelector = createAtomSelector({
// ✅ Selectors for derived state
export const userDisplayNameSelector = selector({
key: 'userDisplayNameSelector',
get: ({ get }) => {
const user = get(currentUserState);
@@ -25,30 +21,13 @@ export const userDisplayNameSelector = createAtomSelector({
},
});
// ✅ Atom factory pattern for dynamic atoms (use createAtomFamilyState)
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export const userByIdState = createAtomFamilyState<User | null, string>({
// ✅ Atom families for dynamic atoms
export const userByIdState = atomFamily<User | null, string>({
key: 'userByIdState',
defaultValue: null,
default: null,
});
```
## Jotai Hooks
```typescript
// useAtomState - read and write
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
// useAtomStateValue - read only
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
// useSetAtomState - write only
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
```
## Provider
Jotai works without a Provider by default. For scoped stores or testing, use `Provider` from `jotai`.
## Local State Guidelines
```typescript
// ✅ Multiple useState for unrelated state
@@ -95,7 +74,7 @@ const increment = useCallback(() => {
```
## Performance Tips
- Use atom factory pattern (createAtomFamilyState) for dynamic data collections
- Derived atoms (createAtomSelector) are automatically memoized by Jotai
- Avoid heavy computations in derived atoms
- Use atom families for dynamic data collections
- Implement proper selector caching
- Avoid heavy computations in selectors
- Batch state updates when possible
+1 -7
View File
@@ -19,10 +19,4 @@ runs:
uses: nrwl/nx-set-shas@v4
- name: Run affected command
shell: bash
env:
NX_CONFIGURATION: ${{ inputs.configuration }}
NX_TASKS: ${{ inputs.tasks }}
NX_PARALLEL: ${{ inputs.parallel }}
NX_TAG: ${{ inputs.tag }}
NX_ARGS: ${{ inputs.args }}
run: npx nx affected --nxBail --configuration="$NX_CONFIGURATION" -t="$NX_TASKS" --parallel="$NX_PARALLEL" --exclude="*,!tag:$NX_TAG" $NX_ARGS
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}' ${{ inputs.args }}
+1 -4
View File
@@ -19,11 +19,8 @@ runs:
- name: Cache primary key builder
id: cache-primary-key-builder
shell: bash
env:
CACHE_KEY: ${{ inputs.key }}
REF_NAME: ${{ github.ref_name }}
run: |
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${CACHE_KEY}-${REF_NAME}" >> "${GITHUB_OUTPUT}"
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
uses: actions/cache/restore@v4
id: restore-cache
@@ -1,80 +0,0 @@
name: Spawn Twenty Docker Image
description: >
Starts a full Twenty instance (server, worker, database, redis) using Docker
Compose. The server is available at http://localhost:3000 for subsequent steps
in the caller's job.
Pulls the specified semver image tag from Docker Hub.
Designed to be consumed from external repositories (e.g., twenty-app).
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag as semver (e.g., v0.40.0, v1.0.0).'
required: true
twenty-repository:
description: 'Twenty repository to checkout docker compose files from.'
required: false
default: 'twentyhq/twenty'
github-token:
description: 'GitHub token for cross-repo checkout. Required when calling from an external repository.'
required: false
default: ${{ github.token }}
outputs:
server-url:
description: 'URL where the Twenty server can be reached'
value: http://localhost:3000
access-token:
description: 'Admin access token for the Twenty instance'
value: ${{ steps.admin-token.outputs.access-token }}
runs:
using: 'composite'
steps:
- name: Validate version
shell: bash
run: |
VERSION="${{ inputs.twenty-version }}"
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::twenty-version must be a semver tag (e.g., v0.40.0). Got: '$VERSION'"
exit 1
fi
- name: Checkout docker compose files
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ inputs.twenty-version }}
token: ${{ inputs.github-token }}
sparse-checkout: |
packages/twenty-docker
sparse-checkout-cone-mode: false
path: .twenty-spawn
- name: Prepare environment
shell: bash
working-directory: ./.twenty-spawn/packages/twenty-docker
run: |
cp .env.example .env
echo "" >> .env
echo "TAG=${{ inputs.twenty-version }}" >> .env
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
echo "SERVER_URL=http://localhost:3000" >> .env
- name: Start Twenty instance
shell: bash
working-directory: ./.twenty-spawn/packages/twenty-docker
run: |
docker compose up -d --wait || {
echo "::error::Docker compose failed to start or health checks timed out"
docker compose logs
exit 1
}
echo "Twenty instance is ready at http://localhost:3000"
- name: Set admin access token
id: admin-token
shell: bash
run: |
ACCESS_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik"
echo "::add-mask::$ACCESS_TOKEN"
echo "access-token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT"
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
deploy-main:
timeout-minutes: 3
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
deploy-tag:
timeout-minutes: 3
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
+1 -1
View File
@@ -16,7 +16,7 @@ permissions:
jobs:
changed-files:
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
outputs:
any_changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
+189 -9
View File
@@ -16,6 +16,8 @@ env:
permissions:
contents: read
pull-requests: write
checks: write
jobs:
changed-files-check:
@@ -32,7 +34,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 45
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -582,16 +584,182 @@ jobs:
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Upload breaking changes report
- name: Comment API Changes on PR
if: always()
uses: actions/upload-artifact@v4
uses: actions/github-script@v7
with:
name: breaking-changes-report
path: |
*-diff.md
*-diff.json
if-no-files-found: ignore
retention-days: 3
script: |
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');
if (graphqlDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
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()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
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()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
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()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
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('🚨') ||
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('🚨') ||
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');
if (graphqlDiff.includes('Breaking changes') || graphqlDiff.includes('BREAKING')) {
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' +
'**This PR title contains "breaking" and breaking changes were detected - the CI will fail as expected.**\n\n' +
'📝 **Action Required**: Please add `BREAKING CHANGE:` to your commit message to trigger a major version bump.\n\n' +
'Example:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
} else {
breakingChangeNote = '\n\n## ⚠️ Breaking Change Protocol\n\n' +
'**Breaking changes detected but PR title does not contain "breaking" - CI will pass but action needed.**\n\n' +
'🔄 **Options**:\n' +
'1. **If this IS a breaking change**: Add "breaking" to your PR title and add `BREAKING CHANGE:` to your commit message\n' +
'2. **If this is NOT a breaking change**: The API diff tool may have false positives - please review carefully\n\n' +
'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({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing API changes comment');
} else {
// Create new comment
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
console.log('Created new API changes comment');
}
} 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,
repo: context.repo.repo,
comment_id: botComment.id,
});
console.log('Deleted existing API changes comment (no changes detected)');
}
}
} catch (error) {
console.log('Could not post comment:', error);
}
- name: Cleanup servers
if: always()
@@ -603,4 +771,16 @@ jobs:
kill $(cat /tmp/main-server.pid) || true
fi
- name: Upload API specifications and diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: api-specifications-and-diffs
path: |
/tmp/main-server.log
/tmp/current-server.log
*-api.json
*-schema-introspection.json
*-diff.md
*-diff.json
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
strategy:
matrix:
task: [lint, typecheck, test]
@@ -50,7 +50,7 @@ jobs:
ci-create-app-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, create-app-test]
steps:
- name: Fail job if any needs failed
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -56,7 +56,7 @@ jobs:
ci-emails-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, emails-test]
steps:
- name: Fail job if any needs failed
+72 -75
View File
@@ -14,8 +14,8 @@ concurrency:
env:
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-ubuntu-latest-8-cores-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-ubuntu-latest-8-cores-runner-${{ github.ref_name }}-${{ github.sha }}
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
jobs:
changed-files-check:
@@ -42,7 +42,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
steps:
@@ -68,7 +68,7 @@ jobs:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
front-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: front-sb-build
strategy:
fail-fast: false
@@ -98,52 +98,52 @@ jobs:
run: npx nx reset:env twenty-front
- name: Run storybook tests
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
# - name: Rename coverage file
# run: |
# if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
# mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# else
# echo "Error: coverage-final.json not found"
# ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
# exit 1
# fi
# - name: Upload coverage artifact
# uses: actions/upload-artifact@v4
# with:
# retention-days: 1
# name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
# path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# merge-reports-and-check-coverage:
# timeout-minutes: 30
# runs-on: ubuntu-latest
# needs: front-sb-test
# env:
# PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
# strategy:
# matrix:
# storybook_scope: [modules, pages, performance]
# steps:
# - uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - uses: actions/download-artifact@v4
# with:
# pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
# merge-multiple: true
# path: coverage-artifacts
# - name: Merge coverage reports
# run: |
# mkdir -p ${{ env.PATH_TO_COVERAGE }}
# npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
# - name: Checking coverage
# run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
- name: Rename coverage file
run: |
if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
else
echo "Error: coverage-final.json not found"
ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
exit 1
fi
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
retention-days: 1
name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
merge-reports-and-check-coverage:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
needs: front-sb-test
env:
PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
strategy:
matrix:
storybook_scope: [modules, pages, performance]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- uses: actions/download-artifact@v4
with:
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
merge-multiple: true
path: coverage-artifacts
- name: Merge coverage reports
run: |
mkdir -p ${{ env.PATH_TO_COVERAGE }}
npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
- name: Checking coverage
run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
front-chromatic-deployment:
timeout-minutes: 30
if: false
needs: front-sb-build
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
@@ -169,7 +169,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
env:
NODE_OPTIONS: '--max-old-space-size=4096'
TASK_CACHE_KEY: front-task-${{ matrix.task }}
@@ -211,7 +211,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
NODE_OPTIONS: "--max-old-space-size=10240"
steps:
@@ -229,14 +229,14 @@ jobs:
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
- 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
runs-on: depot-ubuntu-24.04
needs: [changed-files-check-e2e, front-build]
if: |
always() &&
@@ -296,18 +296,15 @@ jobs:
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: 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 frontend
- 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
@@ -339,23 +336,23 @@ jobs:
- 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/run_results/
retention-days: 30
ci-front-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
needs:
[
changed-files-check,
front-task,
front-build,
# merge-reports-and-check-coverage,
merge-reports-and-check-coverage,
front-sb-test,
front-sb-build,
]
@@ -366,7 +363,7 @@ jobs:
ci-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
needs: [changed-files-check-e2e, e2e-test]
steps:
- name: Fail job if any needs failed
+1 -1
View File
@@ -25,7 +25,7 @@ defaults:
jobs:
create_pr:
timeout-minutes: 10
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -15,7 +15,7 @@ defaults:
jobs:
tag_and_release:
timeout-minutes: 10
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
steps:
- name: Check PR Author
+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
strategy:
matrix:
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
@@ -50,7 +50,7 @@ jobs:
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
@@ -94,7 +94,7 @@ jobs:
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, sdk-test, sdk-e2e-test]
steps:
- name: Fail job if any needs failed
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -143,7 +143,7 @@ jobs:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: server-setup
steps:
- name: Fetch custom Github Actions and base branch history
@@ -164,7 +164,7 @@ jobs:
server-integration-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: server-setup
strategy:
fail-fast: false
@@ -250,7 +250,7 @@ jobs:
ci-server-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, server-setup, server-test, server-integration-test]
steps:
- name: Fail job if any needs failed
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
env:
NODE_OPTIONS: '--max-old-space-size=4096'
strategy:
@@ -47,7 +47,7 @@ jobs:
ci-shared-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, shared-test]
steps:
- name: Fail job if any needs failed
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -92,7 +92,7 @@ jobs:
ci-test-docker-compose-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, test]
steps:
- name: Fail job if any needs failed
+5 -2
View File
@@ -9,7 +9,10 @@ on:
types: [opened, synchronize, reopened, closed]
permissions:
actions: write
checks: write
contents: write
issues: write
pull-requests: write
statuses: write
@@ -22,7 +25,7 @@ concurrency:
jobs:
danger-js:
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
if: github.event.action != 'closed'
steps:
- uses: actions/checkout@v4
@@ -35,7 +38,7 @@ jobs:
congratulate:
timeout-minutes: 3
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
if: github.event.action == 'closed' && github.event.pull_request.merged == true
steps:
- uses: actions/checkout@v4
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -65,7 +65,7 @@ jobs:
ci-website-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, website-build]
steps:
- name: Fail job if any needs failed
-128
View File
@@ -1,128 +0,0 @@
name: CI Zapier
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
SERVER_SETUP_CACHE_KEY: server-setup
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-zapier/**
packages/twenty-server/**
!packages/twenty-zapier/package.json
!packages/twenty-zapier/CHANGELOG.md
server-setup:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
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:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Write .env
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Server / Build
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: Server / Start
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 worker
run: |
npx nx run twenty-server:worker &
echo "Worker started"
- name: Zapier / Build
run: npx nx build twenty-zapier
- name: Zapier / Run Tests
uses: ./.github/actions/nx-affected
with:
tag: scope:zapier
tasks: test
zapier-test:
needs: server-setup
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, validate]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-zapier
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:zapier
tasks: ${{ matrix.task }}
ci-zapier-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, zapier-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+16 -9
View File
@@ -23,7 +23,7 @@ jobs:
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.user.type != 'Bot') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
timeout-minutes: 60
permissions:
contents: write
@@ -87,7 +87,7 @@ jobs:
exit 0
fi
ISSUE_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}"
ENCODED_BRANCH=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$BRANCH")
ENCODED_BRANCH=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$BRANCH', safe=''))")
PR_URL="https://github.com/${{ github.repository }}/compare/main...${ENCODED_BRANCH}?quick_pull=1"
BODY="⚠️ Claude ran out of turns before creating a PR. Work has been pushed to [\`$BRANCH\`](https://github.com/${{ github.repository }}/tree/$ENCODED_BRANCH).\n\n[**Create PR →**]($PR_URL)"
if [ -n "$ISSUE_NUMBER" ]; then
@@ -96,7 +96,7 @@ jobs:
claude-cross-repo:
if: github.event_name == 'repository_dispatch'
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
timeout-minutes: 60
permissions:
contents: write
@@ -157,11 +157,18 @@ jobs:
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Dispatch response to ci-privileged
- name: Post response to source issue
if: always()
uses: peter-evans/repository-dispatch@v2
uses: actions/github-script@v7
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: claude-cross-repo-response
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": ${{ toJSON(format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id)) }}}'
github-token: ${{ secrets.TWENTY_DISPATCH_TOKEN }}
script: |
const [owner, repo] = '${{ steps.prompt.outputs.repo }}'.split('/');
const issueNumber = parseInt('${{ steps.prompt.outputs.issue_number }}', 10);
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Claude finished processing this request. [See workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
});
+1 -1
View File
@@ -34,7 +34,7 @@ concurrency:
jobs:
pull_docs_translations:
name: Pull docs translations
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -21,7 +21,7 @@ concurrency:
jobs:
push_docs:
name: Push documentation to Crowdin
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -32,7 +32,7 @@ concurrency:
jobs:
pull_translations:
name: Pull translations
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -17,7 +17,7 @@ concurrency:
jobs:
extract_translations:
name: Extract and upload translations
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
+118
View File
@@ -0,0 +1,118 @@
# 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: depot-ubuntu-24.04
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 }}
-71
View File
@@ -1,71 +0,0 @@
name: Post CI Comments
on:
workflow_run:
workflows: ['GraphQL and OpenAPI Breaking Changes Detection']
types: [completed]
permissions:
actions: read
jobs:
dispatch-breaking-changes:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Get PR number from workflow run
id: pr-info
uses: actions/github-script@v7
with:
script: |
const runId = context.payload.workflow_run.id;
const headSha = context.payload.workflow_run.head_sha;
const headBranch = context.payload.workflow_run.head_branch;
const headRepo = context.payload.workflow_run.head_repository;
// workflow_run.pull_requests is empty for fork PRs,
// so fall back to searching by head SHA
let pullRequests = context.payload.workflow_run.pull_requests;
let prNumber;
if (pullRequests && pullRequests.length > 0) {
prNumber = pullRequests[0].number;
} else {
core.info(`pull_requests is empty (likely a fork PR), searching by SHA ${headSha}`);
const owner = context.repo.owner;
const repo = context.repo.repo;
const headLabel = `${headRepo.owner.login}:${headBranch}`;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
head: headLabel,
per_page: 1,
});
if (prs.length > 0) {
prNumber = prs[0].number;
}
}
if (!prNumber) {
core.info('No pull request found for this workflow run');
core.setOutput('has_pr', 'false');
return;
}
core.setOutput('pr_number', prNumber);
core.setOutput('run_id', runId);
core.setOutput('has_pr', 'true');
core.info(`PR #${prNumber}, Run ID: ${runId}`);
- name: Dispatch to ci-privileged
if: steps.pr-info.outputs.has_pr == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: breaking-changes-report
client-payload: '{"pr_number": ${{ toJSON(steps.pr-info.outputs.pr_number) }}, "run_id": ${{ toJSON(steps.pr-info.outputs.run_id) }}, "repo": ${{ toJSON(github.repository) }}, "branch_state": ${{ toJSON(github.event.workflow_run.head_branch) }}}'
+7 -22
View File
@@ -2,8 +2,13 @@ name: 'Preview Environment Dispatch'
permissions:
contents: write
actions: write
pull-requests: read
on:
# Using pull_request_target instead of pull_request to have access to secrets for external contributors
# Security note: This is safe because we're only using the repository-dispatch action with limited scope
# and not checking out or running any code from the external contributor's PR
pull_request_target:
types: [opened, synchronize, reopened, labeled]
paths:
@@ -19,21 +24,9 @@ concurrency:
jobs:
trigger-preview:
if: |
(github.event.action == 'labeled' && github.event.label.name == 'preview-app') ||
(
(
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
) && (
github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
github.event.action == 'reopened'
)
)
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || (github.event.action == 'labeled' && github.event.label.name == 'preview-app')
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04
steps:
- name: Trigger preview environment workflow
uses: peter-evans/repository-dispatch@v2
@@ -42,11 +35,3 @@ jobs:
repository: ${{ github.repository }}
event-type: preview-environment
client-payload: '{"pr_number": "${{ github.event.pull_request.number }}", "pr_head_sha": "${{ github.event.pull_request.head.sha }}", "repo_full_name": "${{ github.repository }}"}'
- name: Dispatch to ci-privileged for PR comment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: preview-env-url
client-payload: '{"pr_number": ${{ toJSON(github.event.pull_request.number) }}, "keepalive_dispatch_time": ${{ toJSON(github.event.pull_request.updated_at) }}, "repo": ${{ toJSON(github.repository) }}}'
+47 -19
View File
@@ -2,6 +2,7 @@ name: 'Preview Environment Keep Alive'
permissions:
contents: read
pull-requests: write
on:
repository_dispatch:
@@ -54,15 +55,14 @@ jobs:
port: 3000
- name: Start services with correct SERVER_URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
cd packages/twenty-docker/
echo "Setting SERVER_URL to $TUNNEL_URL"
# Update the SERVER_URL with the tunnel URL
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
sed -i '/SERVER_URL=/d' .env
echo "" >> .env
echo "SERVER_URL=$TUNNEL_URL" >> .env
echo "SERVER_URL=${{ steps.expose-tunnel.outputs.tunnel-url }}" >> .env
# Start the services
echo "Docker compose up..."
@@ -99,26 +99,54 @@ jobs:
fi
working-directory: ./
- name: Output tunnel URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
- name: Output tunnel URL to logs
run: |
echo "✅ Preview Environment Ready!"
echo "🔗 Preview URL: $TUNNEL_URL"
echo "🔗 Preview URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}"
echo "⏱️ This environment will be available for 5 hours"
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@v4
- name: Post comment on PR
uses: actions/github-script@v6
with:
name: tunnel-url
path: tunnel-url.txt
retention-days: 1
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const COMMENT_MARKER = '<!-- PR_PREVIEW_ENV -->';
const commentBody = `${COMMENT_MARKER}
🚀 **Preview Environment Ready!**
Your preview environment is available at: ${{ steps.expose-tunnel.outputs.tunnel-url }}
This environment will automatically shut down when the PR is closed or after 5 hours.`;
// Get all comments
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
});
// Find our comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing comment');
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
body: commentBody
});
console.log('Created new comment');
}
- name: Keep tunnel alive for 5 hours
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
@@ -1,58 +0,0 @@
diff --git a/esm/cache.js b/esm/cache.js
index 07cf6d7dd99effb9c3464b620ba67a7f445224f5..248bb527923499a6be8065ee7a3613b55819c58c 100644
--- a/esm/cache.js
+++ b/esm/cache.js
@@ -69,17 +69,20 @@ export class TransformCacheCollection {
this.invalidate(cacheName, filename);
});
}
- invalidateIfChanged(filename, content) {
+ invalidateIfChanged(filename, content, _visited) {
+ const visited = _visited || new Set();
+ if (visited.has(filename)) {
+ return false;
+ }
+ visited.add(filename);
const fileEntrypoint = this.get('entrypoints', filename);
- // We need to check all dependencies of the file
- // because they might have changed as well.
if (fileEntrypoint) {
for (const [, dependency] of fileEntrypoint.dependencies) {
const dependencyFilename = dependency.resolved;
if (dependencyFilename) {
const dependencyContent = fs.readFileSync(dependencyFilename, 'utf8');
- this.invalidateIfChanged(dependencyFilename, dependencyContent);
+ this.invalidateIfChanged(dependencyFilename, dependencyContent, visited);
}
}
}
diff --git a/lib/cache.js b/lib/cache.js
index 0762ed7d3c39b31000f7aa7d8156da15403c8e64..6955410cd3c9ec53cf7a01c8346abc4c47fff791 100644
--- a/lib/cache.js
+++ b/lib/cache.js
@@ -77,17 +77,20 @@ class TransformCacheCollection {
this.invalidate(cacheName, filename);
});
}
- invalidateIfChanged(filename, content) {
+ invalidateIfChanged(filename, content, _visited) {
+ const visited = _visited || new Set();
+ if (visited.has(filename)) {
+ return false;
+ }
+ visited.add(filename);
const fileEntrypoint = this.get('entrypoints', filename);
- // We need to check all dependencies of the file
- // because they might have changed as well.
if (fileEntrypoint) {
for (const [, dependency] of fileEntrypoint.dependencies) {
const dependencyFilename = dependency.resolved;
if (dependencyFilename) {
const dependencyContent = _nodeFs.default.readFileSync(dependencyFilename, 'utf8');
- this.invalidateIfChanged(dependencyFilename, dependencyContent);
+ this.invalidateIfChanged(dependencyFilename, dependencyContent, visited);
}
}
}
+3 -3
View File
@@ -90,7 +90,7 @@ npx nx run twenty-front:graphql:generate --configuration=metadata
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite
- **Frontend**: React 18, TypeScript, Recoil (state management), Emotion (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
@@ -138,7 +138,7 @@ packages/
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- **Recoil** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
@@ -175,7 +175,7 @@ IMPORTANT: Use Context7 for code generation, setup or configuration steps, or li
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern)
- Use **Emotion** for styling with styled-components pattern
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
+1 -1
View File
@@ -109,7 +109,7 @@ Below are a few features we have implemented to date:
- [TypeScript](https://www.typescriptlang.org/)
- [Nx](https://nx.dev/)
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Linaria](https://linaria.dev/) and [Lingui](https://lingui.dev/)
- [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
-4
View File
@@ -83,10 +83,6 @@ export default [
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:zapier'],
},
],
},
],
+4 -4
View File
@@ -126,7 +126,7 @@
"configurations": {
"ci": {
"ci": true,
"maxWorkers": 1
"maxWorkers": 3
},
"coverage": {
"coverageReporters": ["lcov", "text"]
@@ -276,7 +276,7 @@
"generators": {
"@nx/react": {
"application": {
"style": "@linaria/react",
"style": "@emotion/styled",
"linter": "eslint",
"bundler": "vite",
"compiler": "swc",
@@ -284,7 +284,7 @@
"projectNameAndRootFormat": "derived"
},
"library": {
"style": "@linaria/react",
"style": "@emotion/styled",
"linter": "eslint",
"bundler": "vite",
"compiler": "swc",
@@ -292,7 +292,7 @@
"projectNameAndRootFormat": "derived"
},
"component": {
"style": "@linaria/react"
"style": "@emotion/styled"
}
}
},
+14 -13
View File
@@ -2,13 +2,14 @@
"private": true,
"dependencies": {
"@apollo/client": "^3.7.17",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@floating-ui/react": "^0.24.3",
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
"@radix-ui/colors": "^3.0.0",
"@sniptt/guards": "^0.2.0",
"@tabler/icons-react": "^3.31.0",
"@wyw-in-js/babel-preset": "^1.0.6",
"@wyw-in-js/vite": "^0.7.0",
"archiver": "^7.0.1",
"danger-plugin-todos": "^1.3.1",
@@ -40,7 +41,6 @@
"lodash.snakecase": "^4.1.1",
"lodash.upperfirst": "^4.3.1",
"microdiff": "^1.3.2",
"next-with-linaria": "^1.3.0",
"planer": "^1.2.0",
"pluralize": "^8.0.0",
"react": "^18.2.0",
@@ -48,7 +48,8 @@
"react-responsive": "^9.0.2",
"react-router-dom": "^6.4.4",
"react-tooltip": "^5.13.1",
"remark-gfm": "^4.0.1",
"recoil": "^0.7.7",
"remark-gfm": "^3.0.1",
"rxjs": "^7.2.0",
"semver": "^7.5.4",
"slash": "^5.1.0",
@@ -83,11 +84,11 @@
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-docs": "^10.2.13",
"@storybook/addon-links": "^10.2.13",
"@storybook/addon-vitest": "^10.2.13",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-links": "^10.1.11",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.2.13",
"@storybook/react-vite": "^10.1.11",
"@storybook/test-runner": "^0.24.2",
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.11.1",
@@ -159,7 +160,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.2.13",
"eslint-plugin-storybook": "^10.1.11",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"http-server": "^14.1.1",
@@ -175,9 +176,9 @@
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.20",
"storybook": "^10.2.13",
"storybook": "^10.1.11",
"storybook-addon-mock-date": "2.0.0",
"storybook-addon-pseudo-states": "^10.2.13",
"storybook-addon-pseudo-states": "^10.1.11",
"supertest": "^6.1.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.2.3",
@@ -201,10 +202,10 @@
"type-fest": "4.10.1",
"typescript": "5.9.2",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"prosemirror-view": "1.40.0",
"prosemirror-transform": "1.10.4",
"@lingui/core": "5.1.2",
"@types/qs": "6.9.16",
"@wyw-in-js/transform@npm:0.6.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch",
"@wyw-in-js/transform@npm:0.7.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch"
"@types/qs": "6.9.16"
},
"version": "0.2.1",
"nx": {},
+6 -6
View File
@@ -31,6 +31,10 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# If you don't use yarn@4
corepack enable
yarn install
# Get help and list all available commands
yarn twenty help
@@ -41,7 +45,7 @@ yarn twenty auth:login
yarn twenty entity:add
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated)
# (also auto-generates a typed API client in node_modules/twenty-sdk/generated)
yarn twenty app:dev
# Watch your application's function logs
@@ -50,9 +54,6 @@ yarn twenty function:logs
# Execute a function with a JSON payload
yarn twenty function:execute -n my-function -p '{"key": "value"}'
# Execute the pre-install function
yarn twenty function:execute --preInstall
# Execute the post-install function
yarn twenty function:execute --postInstall
@@ -95,7 +96,6 @@ In interactive mode, you can pick from:
**Core files (always created):**
- `application-config.ts` — Application metadata configuration
- `roles/default-role.ts` — Default role for logic functions
- `logic-functions/pre-install.ts` — Pre-install logic function (runs before app installation)
- `logic-functions/post-install.ts` — Post-install logic function (runs after app installation)
- TypeScript configuration, ESLint, package.json, .gitignore
- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk
@@ -114,7 +114,7 @@ In interactive mode, you can pick from:
- Use `yarn twenty auth:login` to authenticate with your Twenty workspace.
- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- Two typed API clients are autogenerated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- Types are autogenerated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`.
## Publish your application
+1 -2
View File
@@ -1,5 +1,5 @@
const jestConfig = {
displayName: 'create-twenty-app',
displayName: 'twenty-cli',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
@@ -15,7 +15,6 @@ const jestConfig = {
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^package.json$': '<rootDir>/package.json',
},
moduleFileExtensions: ['ts', 'js'],
extensionsToTreatAsEsm: ['.ts'],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.6.3",
"version": "0.6.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -3,9 +3,6 @@
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
@@ -233,9 +233,9 @@ export class CreateAppCommand {
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(` cd ${dirName}`));
console.log(
chalk.gray(' yarn twenty auth:login # Authenticate with Twenty'),
);
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
console.log(chalk.gray(` corepack enable # if you don't use yarn@4`));
console.log(chalk.gray(` yarn install # if you don't use yarn@4`));
console.log(chalk.gray(' yarn auth:login # Authenticate with Twenty'));
console.log(chalk.gray(' yarn app:dev # Start dev mode'));
}
}
@@ -4,8 +4,8 @@ import { copyBaseApplicationProject } from '@/utils/app-template';
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import { join } from 'path';
import createTwentyAppPackageJson from 'package.json';
// 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 {
@@ -41,6 +41,7 @@ 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)}`,
@@ -50,6 +51,7 @@ describe('copyBaseApplicationProject', () => {
});
afterEach(async () => {
// Clean up temp directory after each test
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
@@ -64,12 +66,15 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Verify src/ folder exists
const srcAppPath = join(testAppDirectory, 'src');
expect(await fs.pathExists(srcAppPath)).toBe(true);
// Verify application-config.ts exists in src/
const appConfigPath = join(srcAppPath, APPLICATION_FILE_NAME);
expect(await fs.pathExists(appConfigPath)).toBe(true);
// Verify default-role.ts exists in src/
const roleConfigPath = join(srcAppPath, 'roles', DEFAULT_ROLE_FILE_NAME);
expect(await fs.pathExists(roleConfigPath)).toBe(true);
});
@@ -89,9 +94,7 @@ describe('copyBaseApplicationProject', () => {
const packageJson = await fs.readJson(packageJsonPath);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.version).toBe('0.1.0');
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.dependencies['twenty-sdk']).toBe('latest');
expect(packageJson.scripts['twenty']).toBe('twenty');
});
@@ -140,22 +143,27 @@ describe('copyBaseApplicationProject', () => {
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
// Verify it uses defineApplication
expect(appConfigContent).toContain(
"import { defineApplication } from 'twenty-sdk'",
);
expect(appConfigContent).toContain('export default defineApplication({');
// Verify it imports the role identifier
expect(appConfigContent).toContain(
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
);
// Verify display name and description
expect(appConfigContent).toContain("displayName: 'My Test App'");
expect(appConfigContent).toContain("description: 'A test application'");
// Verify it has a universalIdentifier (UUID format)
expect(appConfigContent).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
// Verify it references the role
expect(appConfigContent).toContain(
'defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
@@ -178,24 +186,29 @@ describe('copyBaseApplicationProject', () => {
);
const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8');
// Verify it uses defineRole
expect(roleConfigContent).toContain(
"import { defineRole } from 'twenty-sdk'",
);
expect(roleConfigContent).toContain('export default defineRole({');
// Verify it exports the universal identifier constant
expect(roleConfigContent).toContain(
'export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
// Verify role label includes app name
expect(roleConfigContent).toContain(
"label: 'My Test App default function role'",
);
// Verify default permissions
expect(roleConfigContent).toContain('canReadAllObjectRecords: true');
expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true');
expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true');
expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false');
// Verify it has a universalIdentifier (UUID format)
expect(roleConfigContent).toMatch(
/universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER/,
);
@@ -210,6 +223,7 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Verify fs.copy was called with correct destination
expect(fs.copy).toHaveBeenCalledTimes(1);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('base-application'),
@@ -233,6 +247,7 @@ describe('copyBaseApplicationProject', () => {
});
it('should generate unique UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
@@ -243,6 +258,7 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
@@ -253,6 +269,7 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Read both app configs
const firstAppConfig = await fs.readFile(
join(firstAppDir, 'src', APPLICATION_FILE_NAME),
'utf8',
@@ -262,6 +279,7 @@ describe('copyBaseApplicationProject', () => {
'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];
@@ -273,6 +291,7 @@ describe('copyBaseApplicationProject', () => {
});
it('should generate unique role UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
@@ -283,6 +302,7 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
@@ -303,6 +323,7 @@ describe('copyBaseApplicationProject', () => {
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstRoleConfig.match(uuidRegex)?.[1];
@@ -354,18 +375,6 @@ describe('copyBaseApplicationProject', () => {
),
),
).toBe(true);
// Install functions should always exist
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'pre-install.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'post-install.ts'),
),
).toBe(true);
});
});
@@ -381,6 +390,7 @@ describe('copyBaseApplicationProject', () => {
const srcPath = join(testAppDirectory, 'src');
// Core files should exist
expect(await fs.pathExists(join(srcPath, APPLICATION_FILE_NAME))).toBe(
true,
);
@@ -388,18 +398,7 @@ describe('copyBaseApplicationProject', () => {
await fs.pathExists(join(srcPath, 'roles', DEFAULT_ROLE_FILE_NAME)),
).toBe(true);
// Install functions should always exist (not gated by exampleOptions)
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'pre-install.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'post-install.ts'),
),
).toBe(true);
// Example files should not exist
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
@@ -677,124 +676,4 @@ describe('copyBaseApplicationProject', () => {
expect(content).toContain('position: 0');
});
});
describe('pre-install logic function', () => {
it('should create pre-install.ts with definePreInstallLogicFunction and typed payload', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const preInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'pre-install.ts',
);
expect(await fs.pathExists(preInstallPath)).toBe(true);
const content = await fs.readFile(preInstallPath, 'utf8');
expect(content).toContain(
"import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'",
);
expect(content).toContain(
'export default definePreInstallLogicFunction({',
);
expect(content).toContain("name: 'pre-install'");
expect(content).toContain('timeoutSeconds: 300');
expect(content).toContain(
'const handler = async (payload: InstallLogicFunctionPayload): Promise<void>',
);
expect(content).toContain('payload.previousVersion');
// Verify it has a universalIdentifier (UUID format)
expect(content).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
});
it('should always create pre-install.ts regardless of example options', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const preInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'pre-install.ts',
);
expect(await fs.pathExists(preInstallPath)).toBe(true);
});
});
describe('post-install logic function', () => {
it('should create post-install.ts with definePostInstallLogicFunction and typed payload', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const postInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'post-install.ts',
);
expect(await fs.pathExists(postInstallPath)).toBe(true);
const content = await fs.readFile(postInstallPath, 'utf8');
expect(content).toContain(
"import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'",
);
expect(content).toContain(
'export default definePostInstallLogicFunction({',
);
expect(content).toContain("name: 'post-install'");
expect(content).toContain('timeoutSeconds: 300');
expect(content).toContain(
'const handler = async (payload: InstallLogicFunctionPayload): Promise<void>',
);
expect(content).toContain('payload.previousVersion');
// Verify it has a universalIdentifier (UUID format)
expect(content).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
});
it('should always create post-install.ts regardless of example options', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const postInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'post-install.ts',
);
expect(await fs.pathExists(postInstallPath)).toBe(true);
});
});
});
@@ -4,7 +4,6 @@ import { v4 } from 'uuid';
import { ASSETS_DIR } from 'twenty-shared/application';
import { type ExampleOptions } from '@/types/scaffolding-options';
import createTwentyAppPackageJson from 'package.json';
const SRC_FOLDER = 'src';
@@ -98,12 +97,6 @@ export const copyBaseApplicationProject = async ({
});
}
await createDefaultPreInstallFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'pre-install.ts',
});
await createDefaultPostInstallFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
@@ -147,7 +140,8 @@ generated
# dev
/dist/
.twenty
.twenty/*
!.twenty/output/
# production
/build
@@ -274,36 +268,6 @@ export default defineLogicFunction({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPreInstallFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPostInstallFunction = async ({
appDirectory,
fileFolder,
@@ -315,14 +279,16 @@ const createDefaultPostInstallFunction = async ({
}) => {
const universalIdentifier = v4();
const content = `import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const content = `import { defineLogicFunction } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default definePostInstallLogicFunction({
universalIdentifier: '${universalIdentifier}',
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
@@ -511,12 +477,14 @@ const createApplicationConfig = async ({
}) => {
const content = `import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
`;
@@ -546,9 +514,10 @@ const createPackageJson = async ({
lint: 'eslint',
'lint:fix': 'eslint --fix',
},
dependencies: {},
dependencies: {
'twenty-sdk': 'latest',
},
devDependencies: {
'twenty-sdk': createTwentyAppPackageJson.version,
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
@@ -6,13 +6,7 @@ const execPromise = promisify(exec);
export const install = async (root: string) => {
try {
await execPromise('corepack enable', { cwd: root });
} catch (error: any) {
console.warn(chalk.yellow('corepack enabled failed:'), error.stderr);
}
try {
await execPromise('yarn install', { cwd: root });
await execPromise('yarn', { cwd: root });
} catch (error: any) {
console.error(chalk.red('yarn install failed:'), error.stdout);
}
+1 -2
View File
@@ -11,8 +11,7 @@
"noEmit": true,
"types": ["jest", "node"],
"paths": {
"@/*": ["./src/*"],
"package.json": ["./package.json"]
"@/*": ["./src/*"]
},
"jsx": "react"
},
@@ -1,54 +0,0 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: 'ac1d2ed1-8835-4bd4-9043-28b46fdda465',
displayName: 'Apollo enrichment',
description: 'Data enrichment with Apollo to keep your data accurate',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
settingsCustomTabFrontComponentUniversalIdentifier: '50d59f7c-eada-4731-aacd-8e45371e1040',
applicationVariables: {
APOLLO_CLIENT_ID: {
universalIdentifier: '5852219e-7757-463e-9e7c-80980203794c',
isSecret: true,
value: '',
description: 'Apollo Client ID',
},
APOLLO_CLIENT_SECRET: {
universalIdentifier: 'a032349d-9458-4381-8505-82547276434a',
isSecret: true,
value: '',
description: 'Apollo Client Secret',
},
APOLLO_OAUTH_URL: {
universalIdentifier: '1d42411c-5809-4093-873a-8121b1302475',
isSecret: false,
value: '',
description: 'Apollo OAuth URL',
},
APOLLO_REDIRECT_URI: {
universalIdentifier: 'c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f',
isSecret: false,
value: '',
description: 'Apollo OAuth redirect URI',
},
APOLLO_REGISTERED_URL: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620770',
isSecret: false,
value: '',
description: 'Apollo registered URL',
},
APOLLO_ACCESS_TOKEN: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620771',
isSecret: true,
value: '',
description: 'Apollo access token',
},
APOLLO_REFRESH_TOKEN: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620772',
isSecret: true,
value: '',
description: 'Apollo refresh token',
},
},
});
@@ -1,16 +0,0 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'da15cfc6-3657-457d-8757-4ba11b5bb6e1',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.NUMBER,
name: 'apolloFoundedYear',
label: 'Founded Year',
description: 'Year the company was founded, from Apollo enrichment',
icon: 'IconCalendar',
});
@@ -1,16 +0,0 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: '505532f5-1fc5-4a58-8074-ba9b48650dbc',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.TEXT,
name: 'apolloIndustry',
label: 'Apollo Industry',
description: 'Industry classification from Apollo enrichment',
icon: 'IconBuildingFactory',
});
@@ -1,16 +0,0 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'be15e062-b065-48b4-979c-65b9a50e0cb1',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.TEXT,
name: 'apolloShortDescription',
label: 'Apollo Description',
description: 'Short company description from Apollo enrichment',
icon: 'IconFileDescription',
});
@@ -1,16 +0,0 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'c90ae72d-4ddf-4f22-882f-eef98c91e40e',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.CURRENCY,
name: 'apolloTotalFunding',
label: 'Total Funding',
description: 'Total funding raised by the company, from Apollo enrichment',
icon: 'IconCash',
});
@@ -1,220 +0,0 @@
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { OAuthApplicationVariables } from 'src/logic-functions/get-oauth-application-variables';
import { VERIFY_PAGE_PATH } from 'src/logic-functions/get-verify-page';
import { defineFrontComponent } from 'twenty-sdk';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
`;
const StyledSectionTitle = styled.h3`
color: #333;
font-family: 'Inter', sans-serif;
font-size: 15px;
font-weight: 600;
margin: 0 0 4px 0;
`;
const StyledSectionSubtitle = styled.p`
color: #818181;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 400;
margin: 0 0 12px 0;
`;
const StyledCard = styled.div`
align-items: center;
background: #fff;
border: 1px solid #ebebeb;
border-radius: 8px;
display: flex;
gap: 12px;
padding: 16px;
`;
const StyledIconContainer = styled.div`
align-items: center;
background: #f5f5f5;
border-radius: 8px;
color: #666;
display: flex;
flex-shrink: 0;
height: 40px;
justify-content: center;
width: 40px;
`;
const StyledTextContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const StyledTitle = styled.span`
color: #333;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
`;
const StyledDescription = styled.span`
color: #818181;
font-family: 'Inter', sans-serif;
font-size: 13px;
`;
const StyledLink = styled.a`
align-items: center;
background: #5e5adb;
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 4px;
box-sizing: border-box;
color: #fafafa;
cursor: pointer;
display: inline-flex;
flex-shrink: 0;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
gap: 4px;
height: 32px;
justify-content: center;
padding: 0 12px;
text-decoration: none;
transition: background 0.1s ease;
white-space: nowrap;
&:hover {
background: #4b47b8;
}
&:active {
background: #3c3996;
}
&:focus {
outline: none;
}
`;
const StyledConnectedStatus = styled.span`
align-items: center;
background: #10b981;
border-radius: 4px;
color: #fff;
display: inline-flex;
flex-shrink: 0;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
gap: 6px;
height: 32px;
padding: 0 12px;
white-space: nowrap;
`;
const StyledIcon = styled.img`
height: 24px;
width: 24px;
`;
const APOLLO_ICON_URL = 'https://twenty-icons.com/apollo.io';
const fetchOAuthApplicationVariables = async (): Promise<OAuthApplicationVariables> => {
const backEndUrl = `${process.env.TWENTY_API_URL}/s/oauth/application-variables`;
const response = await fetch(backEndUrl, {
method: 'GET',
});
const data = await response.json();
return data;
};
const buildOAuthUrl = (oauthApplicationVariables: OAuthApplicationVariables): string => {
const { apolloOAuthUrl, apolloClientId, apolloRegisteredUrl } = oauthApplicationVariables;
const redirectUri = `${apolloRegisteredUrl}auth/oauth-propagator/callback`;
const state = encodeURIComponent(`${process.env.TWENTY_API_URL}/s${VERIFY_PAGE_PATH}`);
return `${apolloOAuthUrl}?client_id=${apolloClientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code`;
};
const ApolloOAuthCta = () => {
const [oauthApplicationVariables, setOAuthApplicationVariables] =
useState<OAuthApplicationVariables | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchOAuthApplicationVariables()
.then(setOAuthApplicationVariables)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) {
return (
<StyledContainer>
<StyledSectionTitle>Connect to Apollo</StyledSectionTitle>
<StyledSectionSubtitle>Enrich your contacts with Apollo data</StyledSectionSubtitle>
<StyledCard>
<StyledIconContainer>
<StyledIcon src={APOLLO_ICON_URL} alt="Apollo" />
</StyledIconContainer>
<StyledTextContainer>
<StyledTitle>Apollo OAuth</StyledTitle>
<StyledDescription>Loading...</StyledDescription>
</StyledTextContainer>
</StyledCard>
</StyledContainer>
);
}
if (error || !oauthApplicationVariables) {
return null;
}
const isConnected = Boolean(oauthApplicationVariables.apolloAccessToken);
const oauthUrl = buildOAuthUrl(oauthApplicationVariables);
return (
<StyledContainer>
<StyledSectionTitle>Connect to Apollo</StyledSectionTitle>
<StyledSectionSubtitle>Enrich your contacts with Apollo data</StyledSectionSubtitle>
<StyledCard>
<StyledIconContainer>
<StyledIcon src={APOLLO_ICON_URL} alt="Apollo" />
</StyledIconContainer>
<StyledTextContainer>
<StyledTitle>Apollo OAuth</StyledTitle>
<StyledDescription>
{isConnected
? 'Your Apollo account is connected'
: 'Connect your Apollo account to enrich contacts'}
</StyledDescription>
</StyledTextContainer>
{isConnected ? (
<StyledConnectedStatus>
Connected
</StyledConnectedStatus>
) : (
<StyledLink href={oauthUrl} rel="noopener noreferrer">
Connect
</StyledLink>
)}
</StyledCard>
</StyledContainer>
);
};
export default defineFrontComponent({
universalIdentifier: '50d59f7c-eada-4731-aacd-8e45371e1040',
name: 'apollo-oauth-cta',
description: 'CTA button to connect to Apollo Enrichment via OAuth',
component: ApolloOAuthCta,
});
@@ -1,105 +0,0 @@
import { defineLogicFunction, RoutePayload } from "twenty-sdk";
import { MetadataApiClient } from 'twenty-sdk/generated';
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
type ApolloTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
created_at: number;
};
const getAuthenticationTokenPairs = async (
code: string,
clientId: string,
clientSecret: string,
): Promise<ApolloTokenResponse> => {
const formData = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code: code,
redirect_uri: 'https://hjsm0q38-3000.uks1.devtunnels.ms/auth/oauth-propagator/callback',
});
const response = await fetch('https://app.apollo.io/api/v1/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to exchange code for tokens: ${response.status} - ${errorText}`);
}
return response.json();
};
const handler = async (event: RoutePayload): Promise<any> => {
const { queryStringParameters: { code } } = event;
if (!code) {
throw new Error('Code is required');
}
const apolloClientId = process.env.APOLLO_CLIENT_ID ?? '';
const apolloClientSecret = process.env.APOLLO_CLIENT_SECRET ?? '';
const applicationId = process.env.APPLICATION_ID ?? '';
const metadataClient = new MetadataApiClient({});
const tokenPairs = await getAuthenticationTokenPairs(
code,
apolloClientId,
apolloClientSecret,
);
await metadataClient.mutation({
updateOneApplicationVariable: {
__args: {
key: 'APOLLO_ACCESS_TOKEN',
value: tokenPairs.access_token,
applicationId,
},
},
});
await metadataClient.mutation({
updateOneApplicationVariable: {
__args: {
key: 'APOLLO_REFRESH_TOKEN',
value: tokenPairs.refresh_token,
applicationId,
},
},
});
return {tokenPairs};
};
export default defineLogicFunction({
universalIdentifier: '7ccc63a7-ece1-44c0-adbe-805a1baea03a',
name: 'get-authentication-token-pairs',
description: 'Returns the Apollo authentication token pairs',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: OAUTH_TOKEN_PAIRS_PATH,
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,32 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk';
export type OAuthApplicationVariables = {
apolloClientId: string;
apolloRegisteredUrl: string;
apolloOAuthUrl: string;
apolloAccessToken: string;
apolloRefreshToken: string;
};
const handler = async (): Promise<OAuthApplicationVariables> => {
const apolloClientId = process.env.APOLLO_CLIENT_ID ?? '';
const apolloRegisteredUrl = process.env.APOLLO_REGISTERED_URL ?? '';
const apolloOAuthUrl = process.env.APOLLO_OAUTH_URL ?? '';
const apolloAccessToken = process.env.APOLLO_ACCESS_TOKEN ?? '';
const apolloRefreshToken = process.env.APOLLO_REFRESH_TOKEN ?? '';
return { apolloClientId, apolloRegisteredUrl, apolloOAuthUrl, apolloAccessToken, apolloRefreshToken };
};
export default defineLogicFunction({
universalIdentifier: 'b7c3e8f1-9d4a-4e2b-8f6c-1a5d3e7b9c2f',
name: 'get-oauth-application-variables',
description: 'Returns the Apollo OAuth authorization URL',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/oauth/application-variables',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,90 +0,0 @@
import { defineLogicFunction } from "twenty-sdk";
export const VERIFY_PAGE_PATH = '/oauth/verify';
const buildVerifyPageHtml = (applicationId: string): string => `<!DOCTYPE html>
<html>
<head>
<title>Apollo OAuth - Verifying...</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center; }
.loading { color: #6b7280; }
.success { color: #10b981; }
.error { color: #ef4444; }
.spinner { border: 3px solid #f3f4f6; border-top: 3px solid #3b82f6; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
<script>
(async function() {
const applicationId = ${JSON.stringify(applicationId)};
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const baseUrl = window.location.origin;
function showError(message) {
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('spinner').style.display = 'none';
document.getElementById('title').textContent = '✗ Connection Failed';
document.getElementById('title').className = 'error';
document.getElementById('status').textContent = message;
});
if (window.opener) {
window.opener.postMessage({ type: 'APOLLO_OAUTH_ERROR', error: message }, '*');
}
}
if (!code) {
showError('Authorization code is missing. Please try connecting again.');
return;
}
try {
const response = await fetch(
baseUrl + '/s/oauth/token-pairs?code=' + encodeURIComponent(code),
{
method: 'GET',
headers: { 'Content-Type': 'application/json' }
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error('Failed to get tokens: ' + response.status + ' - ' + errorText);
}
const tokens = await response.json();
window.location.href = 'http://apple.localhost:3001/settings/applications/' + applicationId + '#custom';
} catch (error) {
showError(error.message);
}
})();
</script>
</head>
<body>
<div class="spinner" id="spinner"></div>
<h1 class="loading" id="title">Connecting to Apollo...</h1>
<p id="status">Please wait while we complete the connection.</p>
</body>
</html>`;
const handler = async (): Promise<string> => {
const applicationId = process.env.APPLICATION_ID ?? '';
return buildVerifyPageHtml(applicationId);
};
export default defineLogicFunction({
universalIdentifier: '4d74950a-d9c1-4c66-a799-89c1aea4e6b0',
name: 'get-verify-page',
description: 'Returns the Apollo OAuth verify page',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: VERIFY_PAGE_PATH,
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,234 +0,0 @@
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
type CompanyRecord = {
id: string;
name?: string;
domainName?: {
primaryLinkUrl?: string;
primaryLinkLabel?: string;
};
};
type ApolloOrganization = {
name?: string;
website_url?: string;
linkedin_url?: string;
twitter_url?: string;
estimated_num_employees?: number;
annual_revenue?: number;
total_funding?: number;
street_address?: string;
city?: string;
state?: string;
postal_code?: string;
country?: string;
short_description?: string;
industry?: string;
founded_year?: number;
};
type ApolloEnrichResponse = {
organization?: ApolloOrganization;
};
const extractDomain = (
domainName?: CompanyRecord['domainName'],
): string | undefined => {
const url = domainName?.primaryLinkUrl;
if (!url) {
return undefined;
}
try {
const hostname = new URL(
url.startsWith('http') ? url : `https://${url}`,
).hostname;
return hostname.replace(/^www\./, '');
} catch {
return url.replace(/^(https?:\/\/)?(www\.)?/, '').split('/')[0];
}
};
const fetchApolloEnrichment = async (
domain: string,
): Promise<ApolloOrganization | undefined> => {
const response = await fetch(
`https://api.apollo.io/api/v1/organizations/enrich?domain=${encodeURIComponent(domain)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.APOLLO_ACCESS_TOKEN ?? ''}`,
},
},
);
const data: ApolloEnrichResponse = await response.json();
return data.organization;
};
const buildCompanyUpdateData = (
apolloOrganization: ApolloOrganization,
): Record<string, unknown> => {
const updateData: Record<string, unknown> = {};
if (apolloOrganization.name) {
updateData.name = apolloOrganization.name;
}
if (apolloOrganization.estimated_num_employees) {
updateData.employees = apolloOrganization.estimated_num_employees;
}
if (apolloOrganization.linkedin_url) {
updateData.linkedinLink = {
primaryLinkUrl: apolloOrganization.linkedin_url,
primaryLinkLabel: 'LinkedIn',
};
}
if (apolloOrganization.twitter_url) {
updateData.xLink = {
primaryLinkUrl: apolloOrganization.twitter_url,
primaryLinkLabel: 'X',
};
}
if (apolloOrganization.annual_revenue) {
updateData.annualRecurringRevenue = {
amountMicros: apolloOrganization.annual_revenue * 1_000_000,
currencyCode: 'USD',
};
}
const hasAddress =
apolloOrganization.street_address ||
apolloOrganization.city ||
apolloOrganization.state ||
apolloOrganization.country;
if (hasAddress) {
updateData.address = {
addressStreet1: apolloOrganization.street_address ?? '',
addressCity: apolloOrganization.city ?? '',
addressState: apolloOrganization.state ?? '',
addressPostcode: apolloOrganization.postal_code ?? '',
addressCountry: apolloOrganization.country ?? '',
};
}
if (apolloOrganization.industry) {
updateData.apolloIndustry = apolloOrganization.industry;
}
if (apolloOrganization.short_description) {
updateData.apolloShortDescription = apolloOrganization.short_description;
}
if (apolloOrganization.founded_year) {
updateData.apolloFoundedYear = apolloOrganization.founded_year;
}
if (apolloOrganization.total_funding) {
updateData.apolloTotalFunding = {
amountMicros: apolloOrganization.total_funding * 1_000_000,
currencyCode: 'USD',
};
}
return updateData;
};
const updateCompanyInTwenty = async (
companyId: string,
updateData: Record<string, unknown>,
): Promise<void> => {
const client = new CoreApiClient();
const result = await client.mutation({
updateCompany: {
__args: {
id: companyId,
data: updateData,
},
id: true,
},
});
if (!result.updateCompany) {
throw new Error(`Failed to update company ${companyId}: no result`);
}
};
type CompanyUpdateEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<CompanyRecord>
>;
const handler = async (
event: CompanyUpdateEvent,
): Promise<object | undefined> => {
const { recordId, properties } = event;
const { after: companyAfter } = properties;
const domain = extractDomain(companyAfter?.domainName);
if (!domain) {
return { skipped: true, reason: 'no domain found on company' };
}
const apolloOrganization = await fetchApolloEnrichment(domain);
if (!apolloOrganization) {
return {
skipped: true,
reason: `no Apollo data found for ${domain}`,
};
}
const updateData = buildCompanyUpdateData(apolloOrganization);
if (Object.keys(updateData).length === 0) {
return { skipped: true, reason: 'no enrichment data to apply' };
}
await updateCompanyInTwenty(recordId, updateData);
const result = {
enriched: true,
companyId: recordId,
domain,
updatedFields: Object.keys(updateData),
};
return result;
};
export default defineLogicFunction({
universalIdentifier: '6248b3fe-a8af-404a-8e38-19df98f73d81',
name: 'on-company-updated',
description:
'Enriches company data from Apollo when the company domain is updated',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'company.updated',
updatedFields: ['domainName'],
},
});
@@ -1,13 +0,0 @@
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
};
export default definePostInstallLogicFunction({
universalIdentifier: '08292efc-d7ba-4ec3-ab95-e7c33bd3a3bc',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
@@ -1,13 +0,0 @@
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: 'af7cd86e-149e-466a-8d60-312b6e46d604',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
@@ -1,3 +1,2 @@
.yarn/install-state.gz
.env
.twenty
@@ -9,16 +9,14 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"auth": "twenty auth:login",
"dev": "twenty app:dev",
"build": "twenty app:build",
"typecheck": "twenty app:typecheck",
"uninstall": "twenty app:uninstall",
"entity:add": "twenty entity:add"
"create-entity": "twenty app add",
"dev": "twenty app dev",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
"auth": "twenty auth login"
},
"dependencies": {
"twenty-sdk": "0.6.3"
"twenty-sdk": "0.2.4"
},
"devDependencies": {
"@types/node": "^24.7.2"
@@ -1,55 +1,67 @@
import {
defineLogicFunction,
type CronPayload,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
import type {
FunctionConfig,
DatabaseEventPayload,
ObjectRecordCreateEvent,
CronPayload,
} from 'twenty-sdk';
import { CoreApiClient as Twenty, type CoreSchema } from 'twenty-sdk/generated';
import Twenty, { type Person } from '../../generated';
type CreateNewPostCardParams =
| { name?: string }
| DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Person>>
| DatabaseEventPayload<ObjectRecordCreateEvent<Person>>
| CronPayload;
const handler = async (params: CreateNewPostCardParams) => {
const client = new Twenty();
export const main = async (params: CreateNewPostCardParams) => {
try {
const client = new Twenty();
const name =
'name' in params
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
const name =
'name' in params
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
const createPostCard = await client.mutation({
createPostCard: {
__args: {
data: {
name,
const createPostCard = await client.mutation({
createPostCard: {
__args: {
data: {
name,
},
},
name: true,
id: true,
},
name: true,
id: true,
},
});
});
console.log('createPostCard result', createPostCard);
console.log('createPostCard result', createPostCard);
return createPostCard;
return createPostCard;
} catch (error) {
console.error(error);
throw error;
}
};
export default defineLogicFunction({
export const config: FunctionConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
},
cronTriggerSettings: {
pattern: '0 0 1 1 *',
},
databaseEventTriggerSettings: {
eventName: 'person.created',
},
});
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
},
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron',
pattern: '0 0 1 1 *', // Every year 1st of January
},
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent',
eventName: 'person.created',
},
],
};
@@ -1,6 +1,6 @@
import { defineApplication } from 'twenty-sdk';
import { type ApplicationConfig } from 'twenty-sdk';
export default defineApplication({
const config: ApplicationConfig = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Hello World',
description: 'A simple hello world app',
@@ -14,4 +14,6 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
});
};
export default config;
@@ -1,89 +1,112 @@
import { defineObject, FieldType } from 'twenty-sdk';
import { type Note } from '../../generated';
const POST_CARD_STATUS = {
DRAFT: 'DRAFT',
SENT: 'SENT',
DELIVERED: 'DELIVERED',
RETURNED: 'RETURNED',
} as const;
import {
type AddressField,
Field,
FieldType,
type FullNameField,
Object,
OnDeleteAction,
Relation,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineObject({
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
@Object({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post card',
labelPlural: 'Post cards',
description: 'A post card object',
description: ' A post card object',
icon: 'IconMail',
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldType.TEXT,
name: 'content',
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: FieldType.FULL_NAME,
name: 'recipientName',
label: 'Recipient name',
icon: 'IconUser',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: FieldType.ADDRESS,
name: 'recipientAddress',
label: 'Recipient address',
icon: 'IconHome',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldType.SELECT,
name: 'status',
label: 'Status',
icon: 'IconSend',
defaultValue: `'${POST_CARD_STATUS.DRAFT}'`,
options: [
{
id: 'a1b2c3d4-0001-4000-8000-000000000001',
value: POST_CARD_STATUS.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: 'a1b2c3d4-0002-4000-8000-000000000002',
value: POST_CARD_STATUS.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
id: 'a1b2c3d4-0003-4000-8000-000000000003',
value: POST_CARD_STATUS.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
id: 'a1b2c3d4-0004-4000-8000-000000000004',
value: POST_CARD_STATUS.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldType.DATE_TIME,
name: 'deliveredAt',
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
},
],
});
})
export class PostCard {
@Field({
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldType.TEXT,
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
})
content: string;
@Field({
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: FieldType.FULL_NAME,
label: 'Recipient name',
icon: 'IconUser',
})
recipientName: FullNameField;
@Field({
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: FieldType.ADDRESS,
label: 'Recipient address',
icon: 'IconHome',
})
recipientAddress: AddressField;
@Field({
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconSend',
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
})
status: PostCardStatus;
@Relation({
universalIdentifier: 'c9e2b4f4-b9ad-4427-9b42-9971b785edfe',
type: RelationType.ONE_TO_MANY,
label: 'Notes',
icon: 'IconComment',
inverseSideTargetUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.note.universalIdentifier,
onDelete: OnDeleteAction.CASCADE,
})
notes: Note[];
@Field({
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldType.DATE_TIME,
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
})
deliveredAt?: Date;
}
@@ -1,6 +1,6 @@
import { defineRole, PermissionFlag } from 'twenty-sdk';
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
export default defineRole({
export const functionRole: RoleConfig = {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
description: 'Default role for function Twenty client',
@@ -14,7 +14,7 @@ export default defineRole({
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
@@ -23,11 +23,11 @@ export default defineRole({
],
fieldPermissions: [
{
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
fieldUniversalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
});
};
File diff suppressed because it is too large Load Diff
@@ -3,9 +3,6 @@
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
@@ -1,5 +1,5 @@
{
"name": "apollo-enrich",
"name": "call-recording",
"version": "0.1.0",
"license": "MIT",
"engines": {
@@ -0,0 +1,9 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: '4daa5147-7e70-4e43-b091-c27e1e8a32e3',
displayName: 'Call recording',
description: 'Allows to record calls',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,17 @@
import { defineFrontComponent } from 'twenty-sdk';
export const HelloWorld = () => {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '5c2fd1e8-dd18-4354-9d0e-424beedafde9',
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
});
@@ -0,0 +1,18 @@
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<void> => {
// TODO: implement end recording logic
};
export default defineLogicFunction({
universalIdentifier: '471353f6-5933-417b-8062-9ad0fc44cd7f',
name: 'end-recording',
description: 'Endpoint to end a call recording',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/end-recording',
httpMethod: 'POST',
isAuthRequired: false,
},
});
@@ -0,0 +1,10 @@
import { CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/call-recording-view';
import { defineNavigationMenuItem } from 'twenty-sdk';
export default defineNavigationMenuItem({
universalIdentifier: '5248a62d-7d2e-43a7-ba45-6e8f61876a71',
name: 'Call recordings',
icon: 'IconPhone',
position: 0,
viewUniversalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,50 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER =
'af251b70-85c6-49bd-bf4a-2631f34c8f1a';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'272dca6f-b3aa-49f5-b7ed-39780052f1fe';
export const CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'c581a044-f646-464b-aa4b-56b8ea9bf05a';
export const ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'56185e64-6591-41c1-a3e0-af8de20a5471';
export default defineObject({
universalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'callRecording',
namePlural: 'callRecordings',
labelSingular: 'Call recording',
labelPlural: 'Call recordings',
description: 'A recorded call',
icon: 'IconPhone',
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the call recording',
icon: 'IconAbc',
},
{
universalIdentifier: CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the call recording was created',
icon: 'IconCalendar',
},
{
universalIdentifier: ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'endedAt',
label: 'Ended at',
description: 'When the call ended',
icon: 'IconCalendar',
},
],
});
@@ -1,15 +1,14 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b8faae3f-e174-43fa-ab94-715712ae26cb';
'f9cfb3ce-cb1e-4f55-af85-be45f6059054';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Apollo enrich default function role',
description: 'Apollo enrich default function role',
label: 'Call recording default function role',
description: 'Call recording default function role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: true,
});
@@ -0,0 +1,36 @@
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER, CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineView } from 'twenty-sdk';
export const CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER =
'9c9c09bb-de9f-4248-89f2-e7d91f29c3ed';
export default defineView({
universalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Call recordings',
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconPhone',
position: 0,
fields: [
{
universalIdentifier: 'b5609679-8451-45ec-ad52-5a4e3720af45',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier: 'd791bea6-c49e-4d6f-8864-737ed00276f8',
fieldMetadataUniversalIdentifier: CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier: 'db96d9cf-cbd8-407b-b748-7b12913f018b',
fieldMetadataUniversalIdentifier: ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
],
});
@@ -830,15 +830,6 @@ __metadata:
languageName: node
linkType: hard
"@gar/promise-retry@npm:^1.0.0":
version: 1.0.2
resolution: "@gar/promise-retry@npm:1.0.2"
dependencies:
retry: "npm:^0.13.1"
checksum: 10c0/748a84fb0ab962f7867966f21dc24d1872c53c1656dd3352320fe69ad3b2043f2dfdb3be024c7636ce4904c5ba1da22d0f3558e489c3de578f5bb520f062d0fd
languageName: node
linkType: hard
"@genql/cli@npm:^3.0.3":
version: 3.0.5
resolution: "@genql/cli@npm:3.0.5"
@@ -1615,29 +1606,29 @@ __metadata:
linkType: hard
"@types/node@npm:*":
version: 25.3.1
resolution: "@types/node@npm:25.3.1"
version: 25.3.0
resolution: "@types/node@npm:25.3.0"
dependencies:
undici-types: "npm:~7.18.0"
checksum: 10c0/f4714e3f2c393434cdefbf207f3fe470bcf4d6c5717298c3fc9239e362a57cd25e59603c60e9c956e7d5d0dfb97cf2e037b37cf6dc2f3876f6a963146f7adc6d
checksum: 10c0/7b2b18c9d68047157367fc2f786d4f166d22dc0ad9f82331ca02fb16f2f391854123dbe604dcb938cda119c87051e4bb71dcb9ece44a579f483a6f96d4bd41de
languageName: node
linkType: hard
"@types/node@npm:^22.5.5":
version: 22.19.12
resolution: "@types/node@npm:22.19.12"
version: 22.19.11
resolution: "@types/node@npm:22.19.11"
dependencies:
undici-types: "npm:~6.21.0"
checksum: 10c0/562cd3da33ddbe36b753e02e5e7158d44878edf78cc5389d16ff626b5afb5e5d8c0baeec56a08650d8c0581696a209271ca9920ab510c510e315aa6e5cad6c06
checksum: 10c0/4b274acf27ec31aa83b50ef22088f83c783e6bcd7dcb40b4834f64f44868b6bf68725214220f15a0c776928f7c0f7f26f03c05cd5868f0526340af3f4af4b58b
languageName: node
linkType: hard
"@types/node@npm:^24.7.2":
version: 24.10.14
resolution: "@types/node@npm:24.10.14"
version: 24.10.13
resolution: "@types/node@npm:24.10.13"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/d26224c654bb028ec9f917152a4e9e562e589b321c1433d5445bc0f742575822ecb9c7aa6a4d90a73a85971369e4daee05e4101d31741ba1745f27139c0d3fe8
checksum: 10c0/4ff0b9b060b5477c0fec5b11a176f294be588104ab546295db65b17a92ba0a6077b52ad92dd3c0d2154198c7f9d0021e6c1d42b00c9ac7ebfd85632afbcc48a4
languageName: node
linkType: hard
@@ -2850,7 +2841,7 @@ __metadata:
languageName: node
linkType: hard
"ansi-regex@npm:^6.2.2":
"ansi-regex@npm:^6.0.1":
version: 6.2.2
resolution: "ansi-regex@npm:6.2.2"
checksum: 10c0/05d4acb1d2f59ab2cf4b794339c7b168890d44dda4bf0ce01152a8da0213aca207802f930442ce8cd22d7a92f44907664aac6508904e75e038fa944d2601b30f
@@ -2896,20 +2887,6 @@ __metadata:
languageName: node
linkType: hard
"apollo-enrich@workspace:.":
version: 0.0.0-use.local
resolution: "apollo-enrich@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
"@types/react": "npm:^18.2.0"
eslint: "npm:^9.32.0"
react: "npm:^18.2.0"
twenty-sdk: "npm:latest"
typescript: "npm:^5.9.3"
typescript-eslint: "npm:^8.50.0"
languageName: unknown
linkType: soft
"archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2":
version: 5.0.2
resolution: "archiver-utils@npm:5.0.2"
@@ -3163,6 +3140,20 @@ __metadata:
languageName: node
linkType: hard
"call-recording@workspace:.":
version: 0.0.0-use.local
resolution: "call-recording@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
"@types/react": "npm:^18.2.0"
eslint: "npm:^9.32.0"
react: "npm:^18.2.0"
twenty-sdk: "npm:latest"
typescript: "npm:^5.9.3"
typescript-eslint: "npm:^8.50.0"
languageName: unknown
linkType: soft
"callsites@npm:^3.0.0":
version: 3.1.0
resolution: "callsites@npm:3.1.0"
@@ -3575,6 +3566,13 @@ __metadata:
languageName: node
linkType: hard
"err-code@npm:^2.0.2":
version: 2.0.3
resolution: "err-code@npm:2.0.3"
checksum: 10c0/b642f7b4dd4a376e954947550a3065a9ece6733ab8e51ad80db727aaae0817c2e99b02a97a3d6cecc648a97848305e728289cf312d09af395403a90c9d4d8a66
languageName: node
linkType: hard
"error-ex@npm:^1.3.1":
version: 1.3.4
resolution: "error-ex@npm:1.3.4"
@@ -5090,10 +5088,9 @@ __metadata:
linkType: hard
"make-fetch-happen@npm:^15.0.0":
version: 15.0.4
resolution: "make-fetch-happen@npm:15.0.4"
version: 15.0.3
resolution: "make-fetch-happen@npm:15.0.3"
dependencies:
"@gar/promise-retry": "npm:^1.0.0"
"@npmcli/agent": "npm:^4.0.0"
cacache: "npm:^20.0.1"
http-cache-semantics: "npm:^4.1.1"
@@ -5103,8 +5100,9 @@ __metadata:
minipass-pipeline: "npm:^1.2.4"
negotiator: "npm:^1.0.0"
proc-log: "npm:^6.0.0"
promise-retry: "npm:^2.0.1"
ssri: "npm:^13.0.0"
checksum: 10c0/b874bf6879fc0b8ef3a3cafdddadea4d956acf94790f8dede1a9d3c74c7886b6cd3eb992616b8e5935e6fd550016a465f10ba51bf6723a0c6f4d98883ae2926b
checksum: 10c0/525f74915660be60b616bcbd267c4a5b59481b073ba125e45c9c3a041bb1a47a2bd0ae79d028eb6f5f95bf9851a4158423f5068539c3093621abb64027e8e461
languageName: node
linkType: hard
@@ -5163,38 +5161,38 @@ __metadata:
linkType: hard
"minimatch@npm:^10.2.2":
version: 10.2.4
resolution: "minimatch@npm:10.2.4"
version: 10.2.2
resolution: "minimatch@npm:10.2.2"
dependencies:
brace-expansion: "npm:^5.0.2"
checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945
checksum: 10c0/098831f2f542cb802e1f249c809008a016e1fef6b3a9eda9cf9ecb2b3d7979083951bd47c0c82fcf34330bd3b36638a493d4fa8e24cce58caf5b481de0f4e238
languageName: node
linkType: hard
"minimatch@npm:^3.1.1, minimatch@npm:^3.1.2, minimatch@npm:^3.1.3":
version: 3.1.5
resolution: "minimatch@npm:3.1.5"
version: 3.1.3
resolution: "minimatch@npm:3.1.3"
dependencies:
brace-expansion: "npm:^1.1.7"
checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70
checksum: 10c0/c1ffce4be47e88df013f66f55176c25a93fdd8ad15735309cf1782f0433a02f363cee298f8763ceaaaf85e70ff7f30dc84a1a8d00a6fb6ca72032e5b51f9b89c
languageName: node
linkType: hard
"minimatch@npm:^5.1.0":
version: 5.1.9
resolution: "minimatch@npm:5.1.9"
version: 5.1.7
resolution: "minimatch@npm:5.1.7"
dependencies:
brace-expansion: "npm:^2.0.1"
checksum: 10c0/4202718683815a7288b13e470160a4f9560cf392adef4f453927505817e01ef6b3476ecde13cfcaed17e7326dd3b69ad44eb2daeb19a217c5500f9277893f1d6
checksum: 10c0/db29063fba9929d07cec7a08386849f4c18b6ffabf1836bd0ab70715f83ff8f596efb354dbc1f5a36653357d06a8b98bb5e3d82159306a9f84ba7ab296e4fa27
languageName: node
linkType: hard
"minimatch@npm:^9.0.4":
version: 9.0.8
resolution: "minimatch@npm:9.0.8"
version: 9.0.6
resolution: "minimatch@npm:9.0.6"
dependencies:
brace-expansion: "npm:^5.0.2"
checksum: 10c0/963e435e30546a5dd09900f916c785efa78aea3b416f0c7b3f5876b23538fecd8056c619b8b00ab545c1bcedbc385566e46735461e45a44e864710f86ed4ed23
checksum: 10c0/b81984f96b64b9b3c6cc7d949950290c456639bf5d89c568041b0505080cfe34102bd1d8586a4dc65878ca02e68ac425e0d8fcb7af10156694c4b8889a0c6c75
languageName: node
linkType: hard
@@ -5698,6 +5696,16 @@ __metadata:
languageName: node
linkType: hard
"promise-retry@npm:^2.0.1":
version: 2.0.1
resolution: "promise-retry@npm:2.0.1"
dependencies:
err-code: "npm:^2.0.2"
retry: "npm:^0.12.0"
checksum: 10c0/9c7045a1a2928094b5b9b15336dcd2a7b1c052f674550df63cc3f36cd44028e5080448175b6f6ca32b642de81150f5e7b1a98b728f15cb069f2dd60ac2616b96
languageName: node
linkType: hard
"proxy-compare@npm:3.0.1, proxy-compare@npm:^3.0.0":
version: 3.0.1
resolution: "proxy-compare@npm:3.0.1"
@@ -5909,10 +5917,10 @@ __metadata:
languageName: node
linkType: hard
"retry@npm:^0.13.1":
version: 0.13.1
resolution: "retry@npm:0.13.1"
checksum: 10c0/9ae822ee19db2163497e074ea919780b1efa00431d197c7afdb950e42bf109196774b92a49fc9821f0b8b328a98eea6017410bfc5e8a0fc19c85c6d11adb3772
"retry@npm:^0.12.0":
version: 0.12.0
resolution: "retry@npm:0.12.0"
checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe
languageName: node
linkType: hard
@@ -6387,11 +6395,11 @@ __metadata:
linkType: hard
"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0":
version: 7.2.0
resolution: "strip-ansi@npm:7.2.0"
version: 7.1.2
resolution: "strip-ansi@npm:7.1.2"
dependencies:
ansi-regex: "npm:^6.2.2"
checksum: 10c0/544d13b7582f8254811ea97db202f519e189e59d35740c46095897e254e4f1aa9fe1524a83ad6bc5ad67d4dd6c0281d2e0219ed62b880a6238a16a17d375f221
ansi-regex: "npm:^6.0.1"
checksum: 10c0/0d6d7a023de33368fd042aab0bf48f4f4077abdfd60e5393e73c7c411e85e1b3a83507c11af2e656188511475776215df9ca589b4da2295c9455cc399ce1858b
languageName: node
linkType: hard
@@ -6569,8 +6577,8 @@ __metadata:
linkType: hard
"twenty-sdk@npm:latest":
version: 0.6.2
resolution: "twenty-sdk@npm:0.6.2"
version: 0.6.0
resolution: "twenty-sdk@npm:0.6.0"
dependencies:
"@chakra-ui/react": "npm:^3.33.0"
"@emotion/react": "npm:^11.14.0"
@@ -6606,7 +6614,7 @@ __metadata:
zod: "npm:^4.1.11"
bin:
twenty: dist/cli.cjs
checksum: 10c0/7c18973fe1a193475c88b00c6f2603b75020139c899a59d3997d044831c45a102c37eb9b87085691adcc46f6f38b22f5c25526f9c6fa1362b3e02670799838b0
checksum: 10c0/7bcf646a309f9bcf8491d2c236f0a0cc62692ddd5f0e4996e2a4c483efa812da8ed54cf8fa904b0478ed31225aa21d78fe0a5188a6d1e7205406802a3e5ed66e
languageName: node
linkType: hard
@@ -1,37 +1,2 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
.yarn/install-state.gz
.env
@@ -2,6 +2,25 @@
Used to manage billing and telemetry of self-hosted instances
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
## Install to your Twenty workspace
```bash
twenty auth login
twenty app sync
```
## Environment Variables
This application requires the following environment variables to be set:
- `TWENTY_API_URL`: The Twenty instance API URL where selfHostingUser records will be created
- `TWENTY_API_KEY`: API key for authentication (generate at `/settings/api-webhooks`)
## Features
### Telemetry Webhook
@@ -9,13 +9,26 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"auth:login": "twenty auth login",
"auth:logout": "twenty auth logout",
"auth:status": "twenty auth status",
"auth:switch": "twenty auth switch",
"auth:list": "twenty auth list",
"app:dev": "twenty app dev",
"app:sync": "twenty app sync",
"entity:add": "twenty entity add",
"function:logs": "twenty function logs",
"function:execute": "twenty function execute",
"app:uninstall": "twenty app uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "0.3.1"
},
"devDependencies": {
"@types/node": "^24.7.2",
"twenty-sdk": "0.6.2"
"@types/node": "^24.7.2"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "a7070f46-3158-4b40-828f-8e6b1febc233"
@@ -0,0 +1,19 @@
import { defineApp } from 'twenty-sdk';
export default defineApp({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
description: 'Used to manage billing and telemetry of self-hosted instances',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
description: 'Twenty API key for creating selfHostingUser records',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
description: 'Twenty API URL (e.g., https://api.twenty.com)',
isSecret: false,
},
},
});
@@ -0,0 +1,18 @@
import { FieldType, defineObject } from 'twenty-sdk';
export default defineObject({
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
nameSingular: 'selfHostingUser',
namePlural: 'selfHostingUsers',
labelSingular: 'Self Hosting User',
labelPlural: 'Self Hosting Users',
fields: [
{
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
description: 'The email of the self hosting user',
universalIdentifier: 'a4b7892c-431a-4d44-973e-a5481652704f',
},
],
});
@@ -0,0 +1,132 @@
import { defineFunction } from 'twenty-sdk';
import { createClient } from '../../generated';
// TODO: import from twenty-sdk when 0.4.0 is deployed
type ServerlessFunctionEvent<TBody = object> = {
headers: Record<string, string | undefined>;
queryStringParameters: Record<string, string | undefined>;
pathParameters: Record<string, string | undefined>;
body: TBody | null;
isBase64Encoded: boolean;
requestContext: {
http: {
method: string;
path: string;
};
};
};
type TelemetryEventPayload = {
action: string;
timestamp: string;
version: string;
payload: {
userId: string | null;
workspaceId: string | null;
payload?: {
events?: Array<{
userId?: string;
userEmail?: string;
userFirstName?: string;
userLastName?: string;
locale?: string;
serverUrl?: string;
}>;
};
};
};
export const main = async (
params: ServerlessFunctionEvent<TelemetryEventPayload>,
): Promise<{ success: boolean; message: string; error?: string }> => {
try {
const { action, payload } = params.body || {};
if (action !== 'user_signup') {
return {
success: true,
message: `Event type '${action}' ignored`,
};
}
const userEmail =
payload?.payload?.events?.[0]?.userEmail ||
payload?.payload?.events?.[0]?.userId;
if (!userEmail) {
return {
success: false,
message: 'No email found in telemetry event',
error: 'Missing userEmail in payload',
};
}
if (
userEmail.toLowerCase().includes('example') ||
userEmail.toLowerCase().includes('test')
) {
return {
success: true,
message: `Email '${userEmail}' ignored (contains test/example data)`,
};
}
const client = createClient({
url: `${process.env.TWENTY_API_URL}/graphql`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
// Create or update selfHostingUser record
const result = await client.mutation({
createSelfHostingUser: {
__args: {
data: {
name:
payload?.payload?.events?.[0]?.userFirstName +
' ' +
payload?.payload?.events?.[0]?.userLastName,
email: {
primaryEmail: userEmail,
additionalEmails: null,
},
},
upsert: true,
},
id: true,
email: {
primaryEmail: true,
},
},
});
return {
success: true,
message: `Self hosting user created/updated: ${result.createSelfHostingUser?.id}`,
};
} catch (error) {
return {
success: false,
message: 'Failed to process telemetry event',
error: error instanceof Error ? error.message : String(error),
};
}
};
export default defineFunction({
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
name: 'telemetry-webhook',
timeoutSeconds: 5,
handler: main,
triggers: [
{
universalIdentifier: '7c8e3f5a-9b4c-4d1e-8f2a-1b3c4d5e6f7a',
type: 'route',
path: '/webhook/telemetry',
httpMethod: 'POST',
isAuthRequired: false,
},
],
});
@@ -1,10 +0,0 @@
import { defineApplication } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineApplication({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
description: 'Used to manage billing and telemetry of self-hosted instances',
defaultRoleUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.roles.defaultRole.universalIdentifier,
});
@@ -1,120 +0,0 @@
export const UNIVERSAL_IDENTIFIERS = {
objects: {
selfHostingUser: {
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
fields: {
name: { universalIdentifier: '682cccbf-9f37-4290-a94c-902c771f61e4' },
email: { universalIdentifier: 'a4b7892c-431a-4d44-973e-a5481652704f' },
personId: {
universalIdentifier: 'b453a43c-1512-48ca-8604-db750ad3ffb8',
},
domain: {
universalIdentifier: '1dfa7d4e-c8f5-4639-b58e-3392a8789f76',
},
userWorkspaceId: {
universalIdentifier: '297a7d6b-e407-4b2d-8c03-8964bc1b7805',
},
userId: {
universalIdentifier: '5c7ba3ce-1473-4e3d-8e7c-31816fcb87d8',
},
locale: {
universalIdentifier: '7b39df37-a22e-4f38-ae77-91cf3ee7c076',
},
serverUrl: {
universalIdentifier: 'f2516b77-2912-4cbb-8838-46ac5a5465d9',
},
serverId: {
universalIdentifier: 'e68a2b15-786d-4e9d-a74d-6d6d577ae721',
},
numberOfEmailsWithSameDomain: {
universalIdentifier: '0bf05db0-6771-4400-91ca-1579ec11e76e',
},
isEnriched: {
universalIdentifier: 'fefe9fd6-23ae-4046-b60b-64d17e9ff7ed',
},
triedToBeEnriched: {
universalIdentifier: 'd32c8cc3-8855-453d-bb7d-9c9c0b3f2128',
},
isPersonalEmail: {
universalIdentifier: 'f4568391-9474-4ed8-8cbb-e36d86e0f5f9',
},
isTwenty: {
universalIdentifier: 'b1acef1f-7c10-47a9-899e-aaca45b36e04',
},
personCity: {
universalIdentifier: 'ca733484-e595-4257-9ca9-9a7802fb8bcb',
},
personCountry: {
universalIdentifier: '18c06357-1b50-4d5b-82cf-1f71f286fbe4',
},
personJobFunction: {
universalIdentifier: '26e7e2c7-ea83-41e0-8c07-1fc2549a3fb4',
},
personJobTitle: {
universalIdentifier: '177908e9-1ca6-4762-9518-0df966d3e9fc',
},
personLinkedIn: {
universalIdentifier: '3515683f-7f9f-4b6d-9b16-614824d277b7',
},
personSeniority: {
universalIdentifier: '8b63855a-5915-4d6a-a6ed-ef7d8f8e5dd1',
},
companyAlexaRank: {
universalIdentifier: '7c61335b-cd4b-4eae-8b02-0db746913e36',
},
companyAnnualRevenue: {
universalIdentifier: 'a2367973-aa12-42c2-9577-fe868f61b83b',
},
companyAnnualRevenuePrinted: {
universalIdentifier: 'bc02b6af-8f48-4fde-920d-1fd3e2a8557b',
},
companyDescription: {
universalIdentifier: 'a9bb622e-56b6-42ba-8b03-17a47d707409',
},
companyEmployees: {
universalIdentifier: '8e1dbc58-d444-470f-b8fe-9eed8da4b59e',
},
companyFoundedYear: {
universalIdentifier: '3cf95527-5064-43ab-bf5e-421eb45fac5f',
},
companyFundingLatestStage: {
universalIdentifier: 'a7dcd92a-6811-490b-a8dd-fad1c19091a1',
},
companyFundingTotalAmount: {
universalIdentifier: '6fca8a11-b49a-4081-a7c9-9646f43ad7aa',
},
companyFundingTotalAmountPrinted: {
universalIdentifier: '0078f0f0-2262-4c74-aaf8-4061c6c8a1f3',
},
companyIndustries: {
universalIdentifier: '6b971b9c-6ef5-4497-989e-f9a7c72720cf',
},
companyIndustry: {
universalIdentifier: 'ab84e651-d35b-4e02-8d69-1740af3e22f7',
},
companyLinkedIn: {
universalIdentifier: '4c44b956-f880-434f-b4cd-854b82076e56',
},
companyName: {
universalIdentifier: '1a25412b-f9ce-4406-ac53-f20d1ab8c5ea',
},
companyTags: {
universalIdentifier: 'ceb64d0b-1203-4c6d-af00-39b668f5f891',
},
companyTech: {
universalIdentifier: '11dd57c3-06bb-4722-bb65-96d0a899ca91',
},
},
},
},
roles: {
defaultRole: {
universalIdentifier: '66972e19-9fdb-4336-87ce-442a17fd179c',
},
},
views: {
selfHostingUserView: {
universalIdentifier: 'e903f0ee-52cb-4537-aca8-8940e30b023d',
},
},
};
@@ -1,29 +0,0 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export const SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER =
'9507f244-fdea-47d5-a734-725d4dae43da';
export default defineField({
universalIdentifier: SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER,
name: 'selfHostingUsers',
label: 'Self hosting users',
description: 'Self hosting user related to the person',
type: FieldType.RELATION,
relationTargetFieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personId
.universalIdentifier,
relationTargetObjectMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.universalIdentifier,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
isNullable: true,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,94 +0,0 @@
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { SELF_HOSTING_USER_NAME_SINGULAR } from 'src/objects/selfHostingUser.object';
import { type SelfHostingUser } from 'twenty-sdk/generated/core';
import { CoreApiClient } from 'twenty-sdk/generated';
const handler = async (
params: DatabaseEventPayload<
| ObjectRecordCreateEvent<SelfHostingUser>
| ObjectRecordUpdateEvent<SelfHostingUser>
>,
) => {
const [object, action] = params.name.split('.');
if (object !== SELF_HOSTING_USER_NAME_SINGULAR) {
return;
}
if (!['created', 'updated'].includes(action)) {
return;
}
const email = params.properties.after.email?.primaryEmail;
if (!email) {
return;
}
const client = new CoreApiClient();
const { people } = await client.query({
people: {
edges: { node: { id: true } },
__args: {
filter: {
emails: {
primaryEmail: { eq: email },
},
},
},
},
});
let personId = people?.edges[0]?.node?.id;
if (!personId) {
const { createPerson } = await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: params.properties.after.name?.firstName,
lastName: params.properties.after.name?.lastName,
},
emails: {
primaryEmail: email,
},
},
},
id: true,
},
});
personId = createPerson?.id;
}
await client.mutation({
updateSelfHostingUser: {
__args: {
id: params.properties.after.id,
data: {
personId,
},
},
id: true,
},
});
};
export default defineLogicFunction({
universalIdentifier: '87f0293a-997a-4c7b-85e2-e77462ccf0c5',
name: 'match-telemetry-event-with-people',
description:
'Matches self hosting users with existing people based on email address',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: {
eventName: `${SELF_HOSTING_USER_NAME_SINGULAR}.*`,
},
});
@@ -1,136 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import { type TelemetryEvent } from 'src/logic-functions/types/telemetry-event.type';
export const main = async (
params: RoutePayload<TelemetryEvent>,
): Promise<{
success: boolean;
message: string;
error?: string;
}> => {
try {
const {
action,
workspaceId,
userWorkspaceId,
userId,
userEmail,
userFirstName,
userLastName,
locale,
serverUrl,
serverId,
} = params.body || {};
if (action !== 'user_signup') {
return {
success: true,
message: `Event type '${action}' ignored`,
};
}
if (!userEmail) {
return {
success: true,
message: 'No email found in telemetry event',
};
}
if (
userEmail.toLowerCase().includes('example') ||
userEmail.toLowerCase().includes('test')
) {
return {
success: true,
message: `Email '${userEmail}' ignored (contains test/example data)`,
};
}
const client = new CoreApiClient();
let existingSelfHostingUserId: string | undefined = undefined;
try {
const { selfHostingUser: existingSelfHostingUser } = await client.query({
selfHostingUser: {
__args: {
filter: {
email: { primaryEmail: { eq: userEmail } },
},
},
id: true,
},
});
existingSelfHostingUserId = existingSelfHostingUser?.id;
} catch {
//
}
if (existingSelfHostingUserId) {
await client.mutation({
updateSelfHostingUser: {
__args: {
id: existingSelfHostingUserId,
data: {
name: { firstName: userFirstName, lastName: userLastName },
email: { primaryEmail: userEmail, additionalEmails: null },
userWorkspaceId,
userId,
locale,
serverUrl,
serverId,
},
},
id: true,
},
});
return {
success: true,
message: `Self hosting user ${existingSelfHostingUserId} updated`,
};
}
const { createSelfHostingUser } = await client.mutation({
createSelfHostingUser: {
__args: {
data: {
name: { firstName: userFirstName, lastName: userLastName },
email: { primaryEmail: userEmail, additionalEmails: null },
workspaceId,
userWorkspaceId,
userId,
locale,
serverUrl,
serverId,
},
},
id: true,
},
});
return {
success: true,
message: `Self hosting user ${createSelfHostingUser?.id} created`,
};
} catch (error) {
return {
success: false,
message: 'Failed to process telemetry event',
error: error instanceof Error ? error.message : String(error),
};
}
};
export default defineLogicFunction({
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
name: 'telemetry-webhook',
timeoutSeconds: 10,
handler: main,
httpRouteTriggerSettings: {
path: '/webhook/telemetry',
httpMethod: 'POST',
isAuthRequired: false,
},
});
@@ -1,12 +0,0 @@
export type TelemetryEvent = {
action: string;
workspaceId?: string;
userWorkspaceId?: string;
userId: string;
userEmail?: string;
userFirstName?: string;
userLastName?: string;
locale?: string;
serverUrl: string;
serverId: string;
};
@@ -1,11 +0,0 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineNavigationMenuItem({
universalIdentifier: 'fe3aaca4-9eda-4565-b215-5d268fbf8164',
name: 'Self host user',
icon: 'IconList',
position: 1,
viewUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.views.selfHostingUserView.universalIdentifier,
});
@@ -1,354 +0,0 @@
import {
defineObject,
FieldType,
RelationType,
OnDeleteAction,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
import { SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER } from 'src/fields/self-hosting-user-id';
export const SELF_HOSTING_USER_NAME_SINGULAR = 'selfHostingUser';
export default defineObject({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.universalIdentifier,
nameSingular: SELF_HOSTING_USER_NAME_SINGULAR,
namePlural: 'selfHostingUsers',
labelSingular: 'Self Hosting User',
labelPlural: 'Self Hosting Users',
fields: [
{
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personId
.universalIdentifier,
name: 'person',
label: 'Person',
description: 'Person matching with the self hosting user',
type: FieldType.RELATION,
relationTargetFieldMetadataUniversalIdentifier:
SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
isNullable: true,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'personId',
},
},
{
type: FieldType.FULL_NAME,
name: 'name',
label: 'Name',
description: 'Name of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.name
.universalIdentifier,
},
{
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
description: 'The email of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.email
.universalIdentifier,
},
{
type: FieldType.LINKS,
name: 'domain',
label: 'Domain',
description:
'Domain extracted from the email address (e.g. domain.com / https://domain.com/)',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.domain
.universalIdentifier,
},
{
type: FieldType.UUID,
name: 'userWorkspaceId',
label: 'User workspace Id',
description: 'User workspace id of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userWorkspaceId
.universalIdentifier,
},
{
type: FieldType.UUID,
name: 'userId',
label: 'User Id',
description: 'User id of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userId
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'locale',
label: 'Locale',
description: 'Locale of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.locale
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'serverUrl',
label: 'Server url',
description: 'Server url of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverUrl
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'serverId',
label: 'Server id',
description: 'Server id of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverId
.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'numberOfEmailsWithSameDomain',
label: 'Number of Emails with Same Domain',
description:
'Aggregated count of self hosting users sharing the same business domain',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.numberOfEmailsWithSameDomain.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'isEnriched',
label: 'Is Enriched',
description: 'Whether the record has been enriched',
defaultValue: false,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isEnriched
.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'triedToBeEnriched',
label: 'Tried to Be Enriched',
description: 'Whether an enrichment attempt has been made',
defaultValue: false,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.triedToBeEnriched
.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'isPersonalEmail',
label: 'Is Personal Email',
description: 'Whether the email is a personal email address',
defaultValue: true,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isPersonalEmail
.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'isTwenty',
label: 'Is Twenty',
description: 'Whether the user is from Twenty',
defaultValue: false,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isTwenty
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personCity',
label: 'Person City',
description: 'City of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCity
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personCountry',
label: 'Person Country',
description: 'Country of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCountry
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personJobFunction',
label: 'Person Job Function',
description: 'Job function of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobFunction
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personJobTitle',
label: 'Person Job Title',
description: 'Job title of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobTitle
.universalIdentifier,
},
{
type: FieldType.LINKS,
name: 'personLinkedIn',
label: 'Person LinkedIn',
description: 'LinkedIn profile of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personLinkedIn
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personSeniority',
label: 'Person Seniority',
description: 'Seniority level of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personSeniority
.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'companyAlexaRank',
label: 'Company Alexa Rank',
description: 'Alexa rank of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyAlexaRank
.universalIdentifier,
},
{
type: FieldType.CURRENCY,
name: 'companyAnnualRevenue',
label: 'Company Annual Revenue',
description: 'Annual revenue of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenue.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyAnnualRevenuePrinted',
label: 'Company Annual Revenue Printed',
description: 'Formatted annual revenue of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenuePrinted.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyDescription',
label: 'Company Description',
description: 'Description of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyDescription
.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'companyEmployees',
label: 'Company Employees',
description: 'Number of employees at the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyEmployees
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyFoundedYear',
label: 'Company Founded Year',
description: 'Year the company was founded',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyFoundedYear
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyFundingLatestStage',
label: 'Company Funding Latest Stage',
description: 'Latest funding stage of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingLatestStage.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'companyFundingTotalAmount',
label: 'Company Funding Total Amount',
description: 'Total funding amount of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmount.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyFundingTotalAmountPrinted',
label: 'Company Funding Total Amount Printed',
description: 'Formatted total funding amount of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmountPrinted.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyIndustries',
label: 'Company Industries',
description: 'Industries the company operates in',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustries
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyIndustry',
label: 'Company Industry',
description: 'Primary industry of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustry
.universalIdentifier,
},
{
type: FieldType.LINKS,
name: 'companyLinkedIn',
label: 'Company LinkedIn',
description: 'LinkedIn page of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyLinkedIn
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyName',
label: 'Company Name',
description: 'Name of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyName
.universalIdentifier,
},
{
type: FieldType.ARRAY,
name: 'companyTags',
label: 'Company Tags',
description: 'Tags associated with the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTags
.universalIdentifier,
},
{
type: FieldType.ARRAY,
name: 'companyTech',
label: 'Company Tech',
description: 'Technologies used by the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTech
.universalIdentifier,
},
],
});
@@ -1,13 +0,0 @@
import { defineRole } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineRole({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.roles.defaultRole.universalIdentifier,
label: 'default role',
description: 'Add a description for your role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -1,308 +0,0 @@
import { defineView } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineView({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.views.selfHostingUserView.universalIdentifier,
name: 'Self hosting users',
objectUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.universalIdentifier,
icon: 'IconList',
position: 0,
fields: [
{
universalIdentifier: '243a2401-cd13-440c-8dcd-649e26df36bc',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.name
.universalIdentifier,
position: 0,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'dfa75ef8-d40d-416f-9f1c-3e86edfa9fce',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.email
.universalIdentifier,
position: 1,
isVisible: true,
size: 150,
},
{
universalIdentifier: '15cc9215-eb48-4487-a92e-a25d8e99702f',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.domain
.universalIdentifier,
position: 2,
isVisible: true,
size: 200,
},
{
universalIdentifier: '0f9e4f63-3664-443a-9f06-8a6cc04c1d90',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personId
.universalIdentifier,
position: 2.1,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'dcf88ae8-e71d-452f-b51e-d88cbc6dd273',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userWorkspaceId
.universalIdentifier,
position: 3,
isVisible: true,
},
{
universalIdentifier: 'aad70516-936b-41d1-b6c6-961a22299761',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userId
.universalIdentifier,
position: 4,
isVisible: true,
},
{
universalIdentifier: '8c210eb0-bdda-476e-9f98-42f909872f2a',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.locale
.universalIdentifier,
position: 5,
isVisible: true,
},
{
universalIdentifier: '367abe85-11c4-440f-80a2-663edd6b4231',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverUrl
.universalIdentifier,
position: 6,
isVisible: true,
},
{
universalIdentifier: '32c199d6-ebf3-434b-81b4-e2b59a0518b7',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverId
.universalIdentifier,
position: 6.1,
isVisible: true,
},
{
universalIdentifier: '924ee786-ab93-44be-9d21-941ff9ffe1ac',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.numberOfEmailsWithSameDomain.universalIdentifier,
position: 7,
isVisible: true,
},
{
universalIdentifier: '2feadf3d-e251-4356-add8-7fa70dea5401',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isEnriched
.universalIdentifier,
position: 8,
isVisible: true,
},
{
universalIdentifier: 'de252ae6-c723-4bf7-96cf-d93f5a539f36',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.triedToBeEnriched
.universalIdentifier,
position: 9,
isVisible: true,
},
{
universalIdentifier: 'b121e8e6-b3eb-4f6c-b67e-c7c6d19e1bc5',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isPersonalEmail
.universalIdentifier,
position: 10,
isVisible: true,
},
{
universalIdentifier: '0ada0bcc-8d6b-4df6-bcc1-78ba14cb04e6',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isTwenty
.universalIdentifier,
position: 11,
isVisible: true,
},
{
universalIdentifier: 'ec7c8d51-ea63-41bd-9eb1-995835b94218',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCity
.universalIdentifier,
position: 12,
isVisible: true,
},
{
universalIdentifier: '7522dd84-0d23-48e7-85dd-f0a8d9e275f8',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCountry
.universalIdentifier,
position: 13,
isVisible: true,
},
{
universalIdentifier: '54191cb9-4d5c-466e-affb-d9ba4adeff87',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobFunction
.universalIdentifier,
position: 14,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'ace75fc7-fb20-4e53-a9a2-6a7529befaf0',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobTitle
.universalIdentifier,
position: 15,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'a0b42d61-4553-42eb-aca4-327b9bf9f30e',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personLinkedIn
.universalIdentifier,
position: 16,
isVisible: true,
},
{
universalIdentifier: '74bc7dd2-fe53-4ff4-8778-2768f3439571',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personSeniority
.universalIdentifier,
position: 17,
isVisible: true,
size: 180,
},
{
universalIdentifier: '61b34f41-8d56-472d-ab1e-414703c6ca12',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyAlexaRank
.universalIdentifier,
position: 18,
isVisible: true,
},
{
universalIdentifier: '5bb7d36b-6a73-4832-b41e-f67130a4708f',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenue.universalIdentifier,
position: 19,
isVisible: true,
},
{
universalIdentifier: 'ae6f23ce-006c-41dd-82a1-e9fe7b65bce3',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenuePrinted.universalIdentifier,
position: 20,
isVisible: true,
size: 250,
},
{
universalIdentifier: 'dd2a4728-a743-43bb-b096-9e7bd5125e56',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyDescription
.universalIdentifier,
position: 21,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'ecca02c9-db2e-41e2-b571-b5db75054b56',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyEmployees
.universalIdentifier,
position: 22,
isVisible: true,
},
{
universalIdentifier: '2e76775b-f8b8-4184-8cd3-72d2b93edaa2',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyFoundedYear
.universalIdentifier,
position: 23,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'a7eb002c-6f0c-48ba-a9eb-247c498ad9bd',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingLatestStage.universalIdentifier,
position: 24,
isVisible: true,
size: 240,
},
{
universalIdentifier: '61be97f6-20da-4b2b-861d-32345e0f9953',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmount.universalIdentifier,
position: 25,
isVisible: true,
},
{
universalIdentifier: '55720810-3120-4e76-bcf2-2da9517edbbc',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmountPrinted.universalIdentifier,
position: 26,
isVisible: true,
size: 280,
},
{
universalIdentifier: '01e31752-cbc1-499a-8ecf-504dd402d7e2',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustries
.universalIdentifier,
position: 27,
isVisible: true,
size: 200,
},
{
universalIdentifier: '2e1c8b8b-469b-483e-8348-1fe3d1764e17',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustry
.universalIdentifier,
position: 28,
isVisible: true,
size: 180,
},
{
universalIdentifier: '976cc8ae-6cf8-4c30-8da4-5bf61e799893',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyLinkedIn
.universalIdentifier,
position: 29,
isVisible: true,
},
{
universalIdentifier: '5f0776b3-2849-4b9b-82f0-baa38c6d889d',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyName
.universalIdentifier,
position: 30,
isVisible: true,
},
{
universalIdentifier: '86f0397a-2924-4e5c-a610-3c9ad7bb4923',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTags
.universalIdentifier,
position: 31,
isVisible: true,
},
{
universalIdentifier: '562084f4-1242-4e60-868b-1d9b268a35b0',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTech
.universalIdentifier,
position: 32,
isVisible: true,
},
],
});
@@ -5,14 +5,13 @@
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
@@ -27,5 +26,10 @@
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
File diff suppressed because it is too large Load Diff

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