Compare commits

..
4462 changed files with 275990 additions and 171506 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
+188 -8
View File
@@ -16,6 +16,8 @@ env:
permissions:
contents: read
pull-requests: write
checks: write
jobs:
changed-files-check:
@@ -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
+62 -65
View File
@@ -98,47 +98,47 @@ 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: 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 }}
- 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
@@ -229,12 +229,12 @@ 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: depot-ubuntu-24.04
needs: [changed-files-check-e2e, front-build]
@@ -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,12 +336,12 @@ 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()
@@ -355,7 +352,7 @@ jobs:
changed-files-check,
front-task,
front-build,
# merge-reports-and-check-coverage,
merge-reports-and-check-coverage,
front-sb-test,
front-sb-build,
]
+3
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
-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: depot-ubuntu-24.04-8
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: depot-ubuntu-24.04
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: depot-ubuntu-24.04
needs: [changed-files-check, zapier-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+14 -7
View File
@@ -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
@@ -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})`
});
+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) }}}'
+6 -21
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,19 +24,7 @@ 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: depot-ubuntu-24.04
steps:
@@ -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)
+2 -2
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), Emotion (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)`
+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/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
- [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
-381
View File
@@ -1,381 +0,0 @@
# Emotion → Linaria Migration Plan: twenty-front
## Overview
Migrate all Emotion (`@emotion/styled`, `@emotion/react`) usages in
`packages/twenty-front/src` to Linaria (`@linaria/react`, `@linaria/core`),
following the same patterns already established in the `twenty-ui` package.
Linaria is a **zero-runtime** CSS-in-JS library. Styles are extracted at
build time by [wyw-in-js](https://wyw-in-js.dev/) (the Vite plugin is
`@wyw-in-js/vite`, already configured in `twenty-front/vite.config.ts`).
This means every expression inside a `styled` or `css` template literal
must be statically evaluable at build time — no runtime theme objects,
no closures over component state, no side-effects.
**Total files to migrate: ~998**
| Category | Files | Description |
|---|---|---|
| styled-only | 694 | Import `@emotion/styled` but not `useTheme` |
| styled + useTheme | 224 | Import both `@emotion/styled` and `useTheme` |
| useTheme-only | 79 | Import `useTheme` but not `@emotion/styled` |
| css / Global only | 1 | Import `css` or `Global` from `@emotion/react` only |
## Theme Architecture
Two build-time utilities produce the theme system:
- **`buildThemeReferencingRootCssVariables`** — walks the theme object and
builds a nested mirror where every leaf is a `var(--t-xxx)` string
(evaluated at build time by wyw-in-js)
- **`prepareThemeForRootCssVariableInjection`** — walks the runtime theme
and collects flat `[--css-variable-name, value]` pairs, injected onto
`document.documentElement` by `ThemeCssVariableInjectorEffect`
`themeCssVariables` is the build-time object; every leaf resolves to a CSS
`var()` reference. It is safe to use inside `styled` and `css` templates
because wyw-in-js can evaluate it statically.
## Migration Patterns
### 1. `styled` import
```diff
- import styled from '@emotion/styled';
+ import { styled } from '@linaria/react';
```
### 2. Theme access in styled components
Replace Emotion's `({ theme }) =>` prop-function pattern with static
`themeCssVariables` references:
```diff
+ import { themeCssVariables } from 'twenty-ui/theme';
const StyledTitle = styled.span`
- color: ${({ theme }) => theme.font.color.primary};
- font-size: ${({ theme }) => theme.font.size.lg};
+ color: ${themeCssVariables.font.color.primary};
+ font-size: ${themeCssVariables.font.size.lg};
`;
```
### 3. Spacing
`theme.spacing(N)` is a function; in Linaria it becomes an indexed lookup:
```diff
- margin-top: ${({ theme }) => theme.spacing(3)};
+ margin-top: ${themeCssVariables.spacing[3]};
```
For multi-arg spacing like `theme.spacing(2, 4)``"8px 16px"`:
```diff
- padding: ${({ theme }) => theme.spacing(2, 4)};
+ padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[4]};
```
The spacing scale covers integers 032 plus `0.5` and `1.5`. Any other
fractional values (`0.25`, `0.75`, `1.25`, `2.5`, `3.5`) must be replaced
with literal pixel values (e.g. `theme.spacing(2.5)``10px`).
### 4. `useTheme` → `useContext(ThemeContext)`
For runtime theme access (icon sizes, animation durations, conditional logic
outside of styled components):
```diff
- import { useTheme } from '@emotion/react';
+ import { useContext } from 'react';
+ import { ThemeContext } from 'twenty-ui/theme';
const MyComponent = () => {
- const theme = useTheme();
+ const { theme } = useContext(ThemeContext);
return <Icon size={theme.icon.size.sm} />;
};
```
### 5. `css` template literal
Linaria's `css` (from `@linaria/core`) returns a **class name string**, not
a serialized style object like Emotion's `css`. This has two consequences:
**Standalone usage** — apply via `className`, not the `css` prop:
```diff
- import { css } from '@emotion/react';
+ import { css } from '@linaria/core';
const myClass = css`
text-decoration: none;
`;
- <Link css={myClass} />
+ <Link className={myClass} />
```
**Inside `styled` templates** — do NOT nest `css` tags. Linaria's `css`
returns a class name, not raw CSS text, so interpolating it inside `styled`
produces broken output. Use plain strings instead:
```diff
// WRONG — css`` returns a class name, not CSS text
${({ handle }) =>
handle === 'left'
- ? css`left: ${themeCssVariables.spacing[1]};`
- : css`right: ${themeCssVariables.spacing[1]};`}
+ ? `left: ${themeCssVariables.spacing[1]};`
+ : `right: ${themeCssVariables.spacing[1]};`}
```
### 6. Interpolation return types
wyw-in-js requires prop interpolation functions to return `string | number`.
They must **never** return `false`, `undefined`, or `null`. Replace
short-circuit `&&` with ternary expressions:
```diff
// WRONG — returns false when condition is false
- ${({ isActive }) => isActive && `background: ${themeCssVariables.color.blue};`}
// CORRECT
+ ${({ isActive }) => isActive ? `background: ${themeCssVariables.color.blue};` : ''}
```
### 7. Block interpolations (multi-declaration returns)
Linaria wraps each interpolation result in a single CSS custom property
(`var(--xxx)`). An interpolation that returns **multiple CSS declarations**
produces invalid CSS. Split into one interpolation per property:
```diff
// WRONG — single interpolation returning multiple declarations
- ${({ divider, theme }) => {
- const border = `1px solid ${theme.border.color.light}`;
- return divider === 'left' ? `border-left: ${border}` : `border-right: ${border}`;
- }}
// CORRECT — one interpolation per property
+ border-left: ${({ divider }) =>
+ divider === 'left' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
+ border-right: ${({ divider }) =>
+ divider === 'right' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
```
### 8. CSS var + unit concatenation
CSS custom properties can't be concatenated with unit suffixes directly
(`var(--x)px` is invalid). Use `calc()` to attach units:
```diff
- transition: background ${themeCssVariables.animation.duration.instant}s ease;
+ transition: background calc(${themeCssVariables.animation.duration.instant} * 1s) ease;
```
### 9. `styled(Component)` requires `className`
Linaria's `styled(Component)` works by passing a generated `className` to
the wrapped component. The component **must** accept and forward a
`className` prop — otherwise the styles are silently lost. If the component
doesn't support it, either add `className` support or use a wrapper div.
Linaria also does **not** support Emotion's `shouldForwardProp` option.
Custom props on HTML elements are automatically filtered by Linaria's
runtime (via `@emotion/is-prop-valid`). For custom components, all props are
forwarded — ensure the wrapped component ignores unknown props gracefully.
### 10. `type Theme` → `type ThemeType`
```diff
- import { type Theme } from '@emotion/react';
+ import { type ThemeType } from 'twenty-ui/theme';
```
### 11. Framer Motion integration
Linaria doesn't support `styled(motion.div)` — wrapping a motion element
with `styled()` causes the component body to be stripped at build time by
wyw-in-js. Define the styled component first, then wrap with
`motion.create()`:
```tsx
const StyledBarBase = styled.div`
background-color: ${themeCssVariables.font.color.primary};
height: 100%;
`;
const StyledBar = motion.create(StyledBarBase);
```
### 12. Dynamic styles via CSS variables
When a component needs to compute styles from multiple props with complex
branching logic (e.g. combining `variant`, `accent`, `disabled`, `focus`),
Linaria's prop interpolations become unwieldy. Use a `computeDynamicStyles`
helper that returns a `CSSProperties` object injected via `style={}`,
referenced from the static CSS with `var()`:
```tsx
const StyledButton = styled.button`
background: var(--btn-bg);
border-color: var(--btn-border-color);
&:hover { background: var(--btn-hover-bg); }
`;
const dynamicStyles = useMemo(() => {
const s = computeButtonDynamicStyles(variant, accent, ...);
return {
'--btn-bg': s.background,
'--btn-hover-bg': s.hoverBackground,
} as CSSProperties;
}, [variant, accent, ...]);
return <StyledButton style={dynamicStyles} />;
```
### 13. `Global` component
Replace Emotion's `<Global styles={...} />` with standard CSS or the
`ThemeCssVariableInjectorEffect` pattern from twenty-ui.
### 14. `ThemeProvider`
The `BaseThemeProvider` already wraps children with both Emotion's
`ThemeProvider` and Linaria's `ThemeContextProvider`. Once all Emotion usages
are gone, the Emotion `ThemeProvider` wrapper can be removed.
---
## PR Breakdown
Files are grouped to keep each PR around ~100 files with consistent review
surface. We start with the simplest, lowest-risk modules.
### PR 1 (~97 files) — Small standalone modules
Low-risk modules with mostly simple `styled`-only patterns.
| Module | Files |
|---|---|
| spreadsheet-import | 28 |
| billing | 10 |
| views | 14 |
| navigation-menu-item | 14 |
| blocknote-editor | 7 |
| advanced-text-editor | 7 |
| favorites | 7 |
| navigation | 4 |
| information-banner | 3 |
| sign-in-background-mock | 3 |
### PR 2 (~100 files) — Auth, tiny modules, loading, testing, pages (part 1)
| Module | Files |
|---|---|
| auth | 19 |
| action-menu | 3 |
| object-metadata | 3 |
| onboarding | 2 |
| workspace | 2 |
| file | 2 |
| error-handler | 2 |
| front-components | 1 |
| geo-map | 1 |
| hooks | 1 |
| loading | 5 |
| testing | 5 |
| pages (first ~55 files) | ~55 |
### PR 3 (~97 files) — Pages (remaining) + activities + AI
| Module | Files |
|---|---|
| pages (remaining ~16 files) | ~16 |
| activities | 53 |
| ai | 28 |
### PR 4 (~100 files) — Command-menu + workflow (part 1)
| Module | Files |
|---|---|
| command-menu | 53 |
| workflow (first ~47 files) | ~47 |
### PR 5 (~115 files) — Workflow (remaining) + page-layout
| Module | Files |
|---|---|
| workflow (remaining ~32 files) | ~32 |
| page-layout | 83 |
### PR 6 (~100 files) — UI module (part 1)
| Module | Files |
|---|---|
| ui (first ~100 files) | ~100 |
### PR 7 (~85 files) — UI module (remaining) + object-record (start)
| Module | Files |
|---|---|
| ui (remaining ~23 files) | ~23 |
| object-record (first ~62 files) | ~62 |
### PR 8 (~100 files) — Object-record (continued)
| Module | Files |
|---|---|
| object-record (next ~100 files) | ~100 |
### PR 9 (~100 files) — Settings (part 1)
| Module | Files |
|---|---|
| settings (first ~100 files) | ~100 |
### PR 10 (~102 files) — Settings (part 2) + final cleanup
| Module | Files |
|---|---|
| settings (remaining ~102 files) | ~102 |
| css/Global-only file | 1 |
### Post-migration PR — Remove Emotion
Once all PRs are merged:
- Remove `ThemeProvider` from `@emotion/react` in `BaseThemeProvider`
- Remove `@emotion/styled` and `@emotion/react` dependencies
- Remove `@styled/typescript-styled-plugin` from tsconfig
- Clean up any remaining Emotion-related configuration
---
## Risk Assessment
| Risk | Mitigation |
|---|---|
| wyw-in-js evaluates at build time; dynamic expressions may fail | Use `themeCssVariables` for static theme values; pass dynamic values as component props or via `style={}` CSS variables |
| `theme.spacing(N)` function → `themeCssVariables.spacing[N]` index | Pre-computed for integers 032 plus 0.5 and 1.5; other fractional values → literal pixel values |
| `useTheme` used for runtime logic (not just styles) | Replace with `useContext(ThemeContext)`, destructure `{ theme }` |
| Multi-arg `theme.spacing(a, b, c)` | Split into individual `themeCssVariables.spacing[N]` references |
| `css` tag inside `styled` templates | Linaria `css` returns class name, not CSS text; use plain strings inside `styled` |
| Interpolation returns `false` / `undefined` | wyw-in-js requires `string \| number`; use ternary `? : ''` instead of `&&` |
| Block interpolations (multiple declarations) | Split into one interpolation per CSS property |
| `var(--x)px` concatenation | Use `calc(var(--x) * 1px)` |
| `styled(motion.div)` stripped by wyw-in-js | Use `motion.create(StyledBase)` pattern |
| `styled(Component)` with no `className` prop | Add `className` support to wrapped component or use wrapper div |
| Complex multi-prop style branching | Use `computeDynamicStyles` + `style={}` + `var()` references |
---
## Validation Checklist (per PR)
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx test twenty-front` passes
- [ ] Visual spot-check of affected components in the app
- [ ] No remaining `@emotion/styled` or `@emotion/react` imports in migrated files
-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'],
},
],
},
],
-283
View File
@@ -1,283 +0,0 @@
# npm-Based App Distribution for Twenty
*Technical Design Document -- February 2026*
## Overview
Add npm registry support for distributing Twenty apps (public and private), with per-AppRegistration registry overrides, direct tarball upload as escape hatch, and version upgrade detection.
## Assumptions
- The marketplace install flow (currently a TODO in the resolver and frontend) will be implemented separately. This plan provides the infrastructure that install flow will call into.
- The existing `app:dev` flow (individual file uploads via CLI) remains unchanged.
- The existing GitHub-based marketplace discovery remains as a curated fallback.
## Architecture
```
Developer
/ | \
npm publish | twenty app:push
/ | \
npmjs.com Private Reg Server REST Upload
| | |
v v v
[Discovery Layer] [Direct Upload]
npm search API |
GitHub curated list |
| |
v |
MarketplaceService |
(sourcePackage in DTO) |
| |
v v
AppPackageResolverService <--------+
.npmrc generation
yarn add / tarball extract
|
v
ApplicationSyncService (existing)
WorkspaceMigrationRunnerService
|
v
AppRegistration + Application entities
```
## Phase 1: Entity and Config Changes
### 1a. Extend AppRegistrationEntity
Add four columns to `application-registration.entity.ts`:
- **`sourcePackage`** (text, nullable) -- npm package name, e.g. `"twenty-app-fireflies"` or `"@myorg/twenty-app-crm"`. Null for tarball-only or OAuth-only apps.
- **`tarballFileId`** (uuid, nullable) -- FK to a FileEntity storing a directly-uploaded `.tar.gz`. Null when the app comes from npm.
- **`registryUrl`** (text, nullable) -- per-registration npm registry override. Null means "use the server default `APP_REGISTRY_URL`." This is how a single server can pull public apps from npmjs.com while pulling `@mycompany/*` apps from GitHub Packages.
- **`latestAvailableVersion`** (text, nullable) -- cached latest version from the registry, updated periodically. Compared against `Application.version` to surface upgrade availability.
**Source resolution priority:**
1. `sourcePackage` is set → resolve from npm via `yarn add`
2. `tarballFileId` is set → extract from file storage
3. Neither → OAuth-only app, no server-side code
### 1b. Extend ApplicationEntity.sourceType
Widen the `sourceType` union from `'local'` to `'local' | 'npm' | 'tarball'`:
- `'local'` -- existing behavior (CLI `app:dev`, individual file uploads, workspace-custom)
- `'npm'` -- installed from an npm registry via `yarn add`
- `'tarball'` -- installed from a directly-uploaded tarball
This lets the system distinguish how an app was installed, which matters for upgrade logic (npm apps can check the registry for newer versions; tarball apps cannot).
### 1c. Add server-wide config variables
Add a new `APP_REGISTRY_CONFIG` group to ConfigVariablesGroup:
- **`APP_REGISTRY_URL`** (string, default `https://registry.npmjs.org`) -- default npm registry URL
- **`APP_REGISTRY_TOKEN`** (string, optional, sensitive) -- auth token for the default registry
### 1d. Generate migration
TypeORM migration adding `sourcePackage`, `tarballFileId`, `registryUrl`, `latestAvailableVersion` to `core.applicationRegistration`.
## Phase 2: App Package Resolver Service
### 2a. Create AppPackageResolverService
New service with core method:
```
resolvePackage(appRegistration, options?: { targetVersion? }) → ResolvedPackage | null
```
Returns `{ manifestPath, packageJsonPath, filesDir }` or null for OAuth-only apps.
**Resolution logic:**
```
if sourcePackage:
1. Determine registry: appRegistration.registryUrl ?? APP_REGISTRY_URL
2. Determine auth token for the resolved registry
3. Generate temporary .npmrc in an isolated working directory
4. Run: yarn add <sourcePackage>@<targetVersion ?? latest>
5. Read manifest from node_modules/<sourcePackage>/.twenty/output/manifest.json
6. Return paths
if tarballFileId:
1. Download tarball from FileStorageService
2. Extract to temporary directory
3. Read manifest from extracted files
4. Return paths
else:
return null (OAuth-only)
```
### 2b. Isolated working directories
Each resolution runs in a temporary directory under `{os.tmpdir()}/twenty-app-resolver/{uuid}/`. This avoids contaminating the server's own `node_modules` and isolates apps from each other. Cleaned up after files are copied to storage.
### 2c. .npmrc generation
For scoped packages (`@scope/twenty-app-*`):
```
@scope:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=TOKEN
```
For unscoped packages with a non-default registry:
```
registry=https://my-verdaccio.internal:4873
//my-verdaccio.internal:4873/:_authToken=TOKEN
```
### 2d. Post-resolution file transfer
After resolving, copies files into the app's storage path using the existing FileStorageService layout:
```
{workspaceId}/{applicationUniversalIdentifier}/
built-logic-function/...
built-front-component/...
dependencies/package.json
dependencies/yarn.lock
public-asset/...
source/...
```
This reuses the same FileFolder enum paths that `app:dev` uses, so downstream ApplicationSyncService works unchanged.
## Phase 3: Marketplace Discovery via npm
### 3a. Update MarketplaceService
Add npm-based discovery alongside the existing GitHub path:
- Query npm search API: `GET {registryUrl}/-/v1/search?text=keywords:twenty-app&size=250`
- Map each result to MarketplaceAppDTO using package.json metadata
**Merge strategy:**
1. Fetch from npm search API (apps with `keywords: ["twenty-app"]`)
2. Fetch from GitHub (existing curated list)
3. Merge by `universalIdentifier` -- GitHub entries override npm entries (allowing curation)
4. Cache merged result with existing 1-hour TTL
### 3b. Add sourcePackage to MarketplaceAppDTO
The DTO needs a `sourcePackage: string | null` field so the install flow knows which npm package to resolve. For npm-discovered apps, this is the package name. For GitHub-only apps, this is null.
## Phase 4: Version Upgrade Support
### 4a. Create AppUpgradeService
**Periodic version check (npm-sourced apps only):**
- Fetches `{registryUrl}/{sourcePackage}/latest` from the npm registry
- Stores result in `AppRegistration.latestAvailableVersion`
- Frontend compares against `Application.version` to show "Update available"
**Upgrade trigger:**
1. Resolve the new version via AppPackageResolverService
2. Sync via existing ApplicationSyncService (triggers workspace migration for schema changes)
3. Update Application.version
**Rollback strategy:** If sync fails (e.g., migration validation error), re-resolve the previous version and re-sync. This is possible because npm retains all published versions.
### 4b. Version check scheduling
Lightweight cron or check-on-access pattern. Iterates over AppRegistrations where `sourcePackage IS NOT NULL` and calls `checkForUpdates()`. Frequency: once per hour, matching the existing marketplace cache TTL.
## Phase 5: SDK CLI Commands
### 5a. Finalize `twenty app:build`
Ensure `.twenty/output/` is npm-publishable. The build step generates a `package.json` in the output directory:
```json
{
"name": "twenty-app-fireflies",
"version": "1.2.0",
"keywords": ["twenty-app"],
"twenty": {
"universalIdentifier": "a4df0c0f-c65e-44e5-8436-24814182d4ac"
},
"files": ["manifest.json", "built-logic-function", "built-front-component", "public-asset"]
}
```
The developer then publishes with standard `npm publish` -- no custom command needed.
### 5b. `twenty app:pack` (new command)
```
twenty app:pack [appPath]
```
- Runs `app:build` if `.twenty/output/` doesn't exist or is stale
- Uses existing TarballService to create `{name}-{version}.tar.gz`
- Outputs the file path for manual distribution
### 5c. `twenty app:push` (new command)
```
twenty app:push [appPath] --server <url> --token <token>
```
- Runs `app:pack` to produce the tarball
- Reads universalIdentifier from manifest to find or create the AppRegistration
- Uploads via `POST /api/app-registrations/upload-tarball`
- Reports success with the registration ID
- Reuses `twenty auth:login` credentials if `--server` is not specified
## Phase 6: Server Tarball Upload Endpoint
### 6a. REST controller
```
POST /api/app-registrations/upload-tarball
Content-Type: multipart/form-data
Body: tarball file + optional universalIdentifier
```
**Validation:**
- Max file size: 50MB
- Must be a valid `.tar.gz`
- Extracted contents must contain `manifest.json` with a valid `universalIdentifier`
- The `universalIdentifier` must not conflict with an existing registration owned by a different user
**Flow:**
1. Extract tarball to temp directory
2. Validate manifest structure
3. Find or create AppRegistration by universalIdentifier
4. Store tarball in FileStorageService under `FileFolder.AppTarball`
5. Set `tarballFileId` on the AppRegistration
6. Return the AppRegistration entity
### 6b. Add FileFolder.AppTarball
New enum value `AppTarball = 'app-tarball'` in FileFolder.
## Key Design Decisions
| Decision | Rationale |
|---|---|
| Per-AppRegistration registry override | `registryUrl` on the entity allows mixing registries. Public apps from npmjs.com, private from GitHub Packages/Verdaccio. Server-wide `APP_REGISTRY_URL` is the fallback. |
| npm publish is standard | No custom publish infra. Free versioning, README, `npm audit`, download stats, proven auth model. |
| Tarball as escape hatch | Air-gapped environments, CI pipelines, one-off installs. Cannot auto-upgrade. |
| sourceType distinction | `'npm' \| 'tarball' \| 'local'` lets the system know which upgrade path is available. Only npm apps can check for newer versions. |
| Backward compatible | `app:dev` flow unchanged. GitHub marketplace unchanged. All new fields nullable. |
| Upgrade rollback | Re-resolve previous version from npm on failure. Safe because npm never deletes published versions. |
## Edge Cases
- **npm unreachable**: Timeout after 30s, throw clear error. App remains at current installed version.
- **Package name conflicts**: The `universalIdentifier` in the `twenty` field of `package.json` is the source of truth, not the npm package name. Two packages with the same universalIdentifier conflict at the AppRegistration level (unique index).
- **Scoped vs unscoped packages**: Both work. Scoped packages naturally route to a private registry via `.npmrc` scope mapping.
- **Multiple workspaces, same server**: AppRegistration is server-level (core schema). Application is workspace-level. One AppRegistration can be installed in multiple workspaces at different versions.
Binary file not shown.
+1 -1
View File
@@ -126,7 +126,7 @@
"configurations": {
"ci": {
"ci": true,
"maxWorkers": 1
"maxWorkers": 3
},
"coverage": {
"coverageReporters": ["lcov", "text"]
+11 -8
View File
@@ -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,6 +202,8 @@
"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"
},
+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,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
@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty/*
!.twenty/output/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
@@ -0,0 +1 @@
24.5.0
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
{
"name": "twentyfortwenty",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"eslint": "^9.32.0",
"react": "^18.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,9 @@
## Base documentation
- 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
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
@@ -0,0 +1,51 @@
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
First, authenticate to your workspace:
```bash
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -0,0 +1,29 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -0,0 +1,28 @@
{
"name": "twentyfortwenty",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"eslint": "^9.32.0",
"react": "^18.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,31 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: '0fac8de4-9d11-492b-9e6a-577e11e1c442',
displayName: 'Twenty for Twenty',
description: '',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
applicationVariables: {
CLICKHOUSE_URL: {
universalIdentifier: 'b204304f-d3d3-4ae2-aafa-24b21c159181',
description: 'The URL of the ClickHouse server, including the protocol and port (e.g., http://localhost:8123)',
isSecret: true,
},
CLICKHOUSE_USERNAME: {
universalIdentifier: '711e8ea8-8b19-4cf0-82ab-ab44417312bd',
description: 'The username for authenticating with the ClickHouse server',
isSecret: true,
},
CLICKHOUSE_PASSWORD: {
universalIdentifier: 'faba7ed7-9f94-4202-944b-c25c683e9504',
description: 'The password for authenticating with the ClickHouse server',
isSecret: true,
},
CLICKHOUSE_DATABASE: {
universalIdentifier: '3e6698fa-0c00-49e5-9c4d-34d5b177bff3',
description: 'The name of the ClickHouse database to connect to',
isSecret: true,
},
},
});
@@ -0,0 +1,37 @@
// Field metadata identifiers for cloudUser2 object
// These constants are used by both the object definition and views
export const CLOUD_USER_2_ACTIVITY_STATUS_FIELD_ID =
'56e3d6ce-d57e-4ded-830f-958eb18d4d36';
export const CLOUD_USER_2_AVG_DAILY_PAGEVIEWS_LAST_30D_FIELD_ID =
'1f7062c1-0e96-4599-a59c-6d7427794bb1';
export const CLOUD_USER_2_DATA_LAST_UPDATED_AT_FIELD_ID =
'e134ce6b-5ab0-4e9c-ba32-36d0851aca21';
export const CLOUD_USER_2_DAYS_SINCE_LAST_ACTIVITY_FIELD_ID =
'f305d341-7b12-4939-9299-e5e1b6d1591e';
export const CLOUD_USER_2_EMAIL_FIELD_ID =
'ee110a77-34d7-4c8b-bc10-8560b9e2333a';
export const CLOUD_USER_2_FULL_NAME_FIELD_ID =
'3c9540be-6e42-40f2-8598-973117bbe105';
export const CLOUD_USER_2_IS_ACTIVE_L24H_FIELD_ID =
'efaa6e54-8019-454c-a556-2e340f0b156d';
export const CLOUD_USER_2_IS_ACTIVE_L30D_FIELD_ID =
'86a07ca4-420e-4c82-82f8-8f8dfbfc2dc4';
export const CLOUD_USER_2_IS_ACTIVE_L7D_FIELD_ID =
'87bf8c09-1863-4c65-811f-6fb54a87238e';
export const CLOUD_USER_2_IS_TWENTY_FIELD_ID =
'f6dcd9f3-3f7e-4aea-84a5-41282a68910d';
export const CLOUD_USER_2_LAST_ACTIVITY_DATE_FIELD_ID =
'643ab4a2-b6f2-4d11-9173-6859884b8781';
export const CLOUD_USER_2_PAGE_VIEWS_L24H_FIELD_ID =
'294e95cb-13b3-4163-95f8-e4f31837cb47';
export const CLOUD_USER_2_PAGE_VIEWS_L30D_FIELD_ID =
'8dacb4f1-b4ef-42d6-a917-17c78d6273a7';
export const CLOUD_USER_2_PAGE_VIEWS_L7D_FIELD_ID =
'c7741332-d9b6-42ed-84e2-8afa184b58f3';
export const CLOUD_USER_2_UPDATED_BY_FIELD_ID =
'639c9a30-2926-44c3-ab44-100dbda91c64';
export const CLOUD_USER_2_USER_TENURE_FIELD_ID =
'0cc9ca63-c06f-4422-9325-5e99f98d05ed';
export const CLOUD_USER_2_WORKSPACE_COUNT_FIELD_ID =
'5af6ae62-b741-471b-90bb-7fb6678fc8c9';
@@ -0,0 +1,25 @@
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk';
import {
CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID,
CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID,
} from 'src/fields/cloud-user-workspaces-on-cloud-user.field';
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
export default defineField({
universalIdentifier: CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID,
objectUniversalIdentifier: CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'cloudUser2',
label: 'Cloud User 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'cloudUser2Id',
},
});
@@ -0,0 +1,11 @@
// Field metadata identifiers for cloudUserWorkspace2 object
// These constants are used by both the object definition and views
export const CLOUD_USER_WORKSPACE_2_TWENTY_USER_IDENTIFIER_FIELD_ID =
'ad149693-0df5-4346-90ff-39e6d945b90f';
export const CLOUD_USER_WORKSPACE_2_TWENTY_WORKSPACE_IDENTIFIER_FIELD_ID =
'c5d02b59-ebaf-4604-809c-ce3f6e50dedc';
export const CLOUD_USER_WORKSPACE_2_ID_OF_THE_USER_WORKSPACE_FIELD_ID =
'6a0f8018-be3c-49b0-bb91-8d3e6b44adec';
export const CLOUD_USER_WORKSPACE_2_UPDATED_BY_FIELD_ID =
'29d47f11-1e45-4c67-b495-fcfa45bb67e2';
@@ -0,0 +1,24 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk';
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
export const CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID =
'2290d722-d4b1-47ab-a895-4e2c3163a541';
export const CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID =
'49b08cf9-15e1-4583-826f-943fe3c6b0e8';
export default defineField({
universalIdentifier: CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID,
objectUniversalIdentifier: CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'cloudUserWorkspaces2',
label: 'Cloud User Workspaces 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,24 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk';
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
import { CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-workspace-2';
export const CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID =
'90d9dfc7-7058-4d1c-aac2-1505bd7cb827';
export const CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID =
'7bcd2dda-d1f8-4aa7-a83f-6cf240be80b2';
export default defineField({
universalIdentifier: CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID,
objectUniversalIdentifier: CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'cloudUserWorkspaces2',
label: 'Cloud User Workspaces 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,28 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
export const CLOUD_USERS_2_ON_PERSON_ID =
'f238e91c-11d8-4a27-a39b-c9fa3515d4a0';
export const PERSON_ON_CLOUD_USER_2_ID =
'08d3aff0-7548-4a99-a097-20a0ad2c9ee7';
export default defineField({
universalIdentifier: CLOUD_USERS_2_ON_PERSON_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'cloudUsers2',
label: 'Cloud Users 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PERSON_ON_CLOUD_USER_2_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,83 @@
// Field metadata identifiers for cloudWorkspace2 object
// These constants are used by both the object definition and views
export const CLOUD_WORKSPACE_2_CUSTOM_DOMAIN_FIELD_ID =
'c30799d5-adc0-49be-82a1-6d41b5aa9e82';
export const CLOUD_WORKSPACE_2_WORKSPACE_BUSINESS_DOMAIN_FIELD_ID =
'bfa2f91d-1c6c-48de-bb11-ac2a1e2545a9';
export const CLOUD_WORKSPACE_2_COMPANY_LINKEDIN_FIELD_ID =
'f9f2ef3a-8235-4d21-9a45-d29cc2124456';
export const CLOUD_WORKSPACE_2_DATA_LAST_UPDATED_AT_FIELD_ID =
'fbeaab89-77e0-4e13-97ed-caeb757a38c7';
export const CLOUD_WORKSPACE_2_LAST_PAGE_VIEW_DATE_FIELD_ID =
'7d3d4ba3-5af2-4248-afc7-7f4ce7c82805';
export const CLOUD_WORKSPACE_2_TOTAL_EVER_ACTIVE_WORKSPACE_USERS_FIELD_ID =
'56e99339-dcc4-4950-ae64-1b6ae326af2c';
export const CLOUD_WORKSPACE_2_ACTIVE_USERS_L30D_FIELD_ID =
'e9c703e6-9ffc-497d-9755-018724ac8d66';
export const CLOUD_WORKSPACE_2_ACTIVE_USERS_L7D_FIELD_ID =
'81d8f622-2366-481f-b0e8-b14fb7efa4d5';
export const CLOUD_WORKSPACE_2_ACTIVE_USERS_L24H_FIELD_ID =
'3e53d606-1afa-4d49-be62-52f4bb1cb49e';
export const CLOUD_WORKSPACE_2_WORKSPACE_TENURE_FIELD_ID =
'3fdd272c-73dd-432e-99c7-782f237a7cd8';
export const CLOUD_WORKSPACE_2_PAGE_VIEWS_L30D_FIELD_ID =
'1931f3cd-a984-41e3-b8f5-510f67b508e6';
export const CLOUD_WORKSPACE_2_PAGE_VIEWS_L7D_FIELD_ID =
'1f6a0ef6-3faf-4157-88a7-f624b7655c6b';
export const CLOUD_WORKSPACE_2_PAGE_VIEWS_L24H_FIELD_ID =
'ab0f46aa-9fef-40c6-a229-6ed4e05867d4';
export const CLOUD_WORKSPACE_2_TOTAL_WORKSPACE_USERS_FIELD_ID =
'38095859-2efb-4f40-86a1-aa883fd88e3d';
export const CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_L30D_FIELD_ID =
'4ebd8329-a18c-4606-9ee0-f32fb1c4aa6f';
export const CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_TOTAL_FIELD_ID =
'754f137f-b759-47da-8b57-7de71ad34d37';
export const CLOUD_WORKSPACE_2_ALEXA_RANK_FIELD_ID =
'1627e6b8-e87a-4048-b954-8d2fe09b67c8';
export const CLOUD_WORKSPACE_2_EMPLOYEES_FIELD_ID =
'1d8bb210-b50d-4b38-a611-d78ba865cbae';
export const CLOUD_WORKSPACE_2_IS_ACTIVE_L7D_FIELD_ID =
'42879662-d4fc-4806-bcfb-fdb586827491';
export const CLOUD_WORKSPACE_2_IS_ACTIVE_L24H_FIELD_ID =
'44b2c12f-aedd-459a-848a-ade9fc4620b7';
export const CLOUD_WORKSPACE_2_IS_ACTIVE_L30D_FIELD_ID =
'cac2d3e0-c2ee-4e22-bd96-4fa051a36f73';
export const CLOUD_WORKSPACE_2_IS_ENRICHED_FIELD_ID =
'45cbddfa-54cc-4d94-88d2-db7a7a550414';
export const CLOUD_WORKSPACE_2_INDUSTRY_FIELD_ID =
'a6686308-dfcf-4abb-be0f-e8f48cc579d9';
export const CLOUD_WORKSPACE_2_COMPANY_FOUNDED_YEAR_FIELD_ID =
'0432af7e-7575-486e-a022-f4a625a8ac14';
export const CLOUD_WORKSPACE_2_DESCRIPTION_FIELD_ID =
'40854f41-4175-4b14-9567-c6bfb3f5d441';
export const CLOUD_WORKSPACE_2_LATEST_FUNDING_STAGE_FIELD_ID =
'44aba339-be2e-4364-a894-cf730081c8a6';
export const CLOUD_WORKSPACE_2_COMPANY_NAME_FIELD_ID =
'f11ddfc2-3356-453a-a6e1-494b6c6b12f6';
export const CLOUD_WORKSPACE_2_SUB_DOMAIN_FIELD_ID =
'8fb18f52-44e9-4288-87c1-9650222efb05';
export const CLOUD_WORKSPACE_2_ANNUAL_REVENUE_FIELD_ID =
'8f5dfc54-213b-43b2-a502-631f0f3240d1';
export const CLOUD_WORKSPACE_2_MRR_FIELD_ID =
'67125b6c-ff41-459f-8153-a338ed11c4f0';
export const CLOUD_WORKSPACE_2_POTENTIAL_ARR_FIELD_ID =
'0c38b297-0e9d-47cc-8f8f-a0e5402413ce';
export const CLOUD_WORKSPACE_2_ARR_FIELD_ID =
'f932f94b-c48b-4ef3-9eea-0c8dad370d89';
export const CLOUD_WORKSPACE_2_TOTAL_FUNDING_FIELD_ID =
'ae5da0c9-e9ac-401b-ad48-c37a31c6ecd1';
export const CLOUD_WORKSPACE_2_ACTIVATION_STATUS_FIELD_ID =
'ef44daa9-d272-4680-a19c-caf2bac32b70';
export const CLOUD_WORKSPACE_2_SUBSCRIPTION_STATUS_FIELD_ID =
'a88e8cab-333f-44a3-980e-f2cc22871aee';
export const CLOUD_WORKSPACE_2_PAYMENT_FREQUENCY_FIELD_ID =
'fccc73da-2b6e-434b-921b-e3bcbab1c56c';
export const CLOUD_WORKSPACE_2_NEXT_RENEWAL_DATE_FIELD_ID =
'488b38b4-971e-4c33-b60d-7e7934911579';
export const CLOUD_WORKSPACE_2_CREATOR_EMAIL_FIELD_ID =
'3815b992-a919-4c52-8629-b4b5f89a6060';
export const CLOUD_WORKSPACE_2_TAGS_FIELD_ID =
'0f41485f-4868-4bd6-8d76-709a6b7a54e3';
export const CLOUD_WORKSPACE_2_UPDATED_BY_FIELD_ID =
'252cdff1-facb-4f66-8608-6138a585d099';
@@ -0,0 +1,25 @@
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk';
import {
CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID,
CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID,
} from 'src/fields/cloud-user-workspaces-on-cloud-workspace.field';
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
import { CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-workspace-2';
export default defineField({
universalIdentifier: CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID,
objectUniversalIdentifier: CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'cloudWorkspace2',
label: 'Cloud Workspace 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'cloudWorkspace2Id',
},
});
@@ -0,0 +1,29 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import {
CLOUD_USERS_2_ON_PERSON_ID,
PERSON_ON_CLOUD_USER_2_ID,
} from 'src/fields/cloud-users-2-on-person.field';
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
export default defineField({
universalIdentifier: PERSON_ON_CLOUD_USER_2_ID,
objectUniversalIdentifier: CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: CLOUD_USERS_2_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'personId',
},
});
@@ -0,0 +1,49 @@
import { defineLogicFunction } from 'twenty-sdk';
import { syncCloudUserWorkspaces } from 'src/logic-functions/sync-product-data/sync-cloud-user-workspaces';
import { syncCloudUsers } from 'src/logic-functions/sync-product-data/sync-cloud-users';
import { syncCloudWorkspaces } from 'src/logic-functions/sync-product-data/sync-cloud-workspaces';
const handler = async (): Promise<{ message: string }> => {
try {
console.log('Starting product data sync');
const cloudUsersResult = await syncCloudUsers();
console.log(
`Cloud users sync complete: ${cloudUsersResult.syncedCount} users`,
);
const cloudWorkspacesResult = await syncCloudWorkspaces();
console.log(
`Cloud workspaces sync complete: ${cloudWorkspacesResult.syncedCount} workspaces`,
);
const cloudUserWorkspacesResult = await syncCloudUserWorkspaces();
console.log(
`Cloud user workspaces sync complete: ${cloudUserWorkspacesResult.syncedCount} user workspaces`,
);
return {
message: `Product data sync complete — ${cloudUsersResult.syncedCount} users, ${cloudWorkspacesResult.syncedCount} workspaces, ${cloudUserWorkspacesResult.syncedCount} user workspaces`,
};
} catch (err) {
console.log(err);
throw err;
}
};
export default defineLogicFunction({
universalIdentifier: '3897e059-715e-4a4b-b165-c44f17d2e30a',
name: 'sync-product-data',
description:
'Syncs cloud users, cloud workspaces, and cloud user workspaces from ClickHouse',
timeoutSeconds: 120,
handler,
cronTriggerSettings: {
pattern: '*/10 * * * *',
},
});
@@ -0,0 +1,93 @@
import { z } from 'zod';
import { getApplicationConfig } from 'src/shared/application-config';
import { fetchFromClickHouse } from 'src/shared/clickhouse-client';
import { twentyClient } from 'src/shared/twenty-client';
const clickHouseUserWorkspaceSchema = z.object({
id: z.string(),
workspaceId: z.string(),
userId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string(),
is_active_membership: z.coerce.boolean(),
});
type ClickHouseUserWorkspace = z.infer<typeof clickHouseUserWorkspaceSchema>;
const fetchUserWorkspacesFromClickHouse = async (): Promise<
ClickHouseUserWorkspace[]
> => {
const { clickHouseDatabase } = getApplicationConfig();
// const nowDate = 'now()'
const nowDate = "'2026-02-04 14:28:52.000'";
const query = `
SELECT
*
FROM (
SELECT
*,
row_number() OVER (PARTITION BY id ORDER BY updatedAt DESC) AS rn
FROM
${clickHouseDatabase}.user_workspace
WHERE
updatedAt >= ${nowDate} - INTERVAL 500 MINUTE
AND
updatedAt <= ${nowDate}
)
WHERE
rn = 1
FORMAT
JSONEachRow;
`;
return fetchFromClickHouse(query, clickHouseUserWorkspaceSchema);
};
const buildCloudUserWorkspaceInput = (
userWorkspace: ClickHouseUserWorkspace,
) => ({
id: userWorkspace.id,
twentyUserIdentifier: userWorkspace.userId,
twentyWorkspaceIdentifier: userWorkspace.workspaceId,
idOfTheUserWorkspace: userWorkspace.id,
cloudUser2Id: userWorkspace.userId,
cloudWorkspace2Id: userWorkspace.workspaceId,
});
export const syncCloudUserWorkspaces = async (): Promise<{
syncedCount: number;
}> => {
const userWorkspaces = await fetchUserWorkspacesFromClickHouse();
console.log(
`Fetched ${userWorkspaces.length} user workspaces from ClickHouse`,
);
if (userWorkspaces.length === 0) {
return { syncedCount: 0 };
}
const cloudUserWorkspaceInputs = userWorkspaces.map(
buildCloudUserWorkspaceInput,
);
console.log(
`Batch-upserting ${cloudUserWorkspaceInputs.length} cloud user workspaces`,
);
await twentyClient.mutation({
createCloudUserWorkspaces2: {
__args: {
data: cloudUserWorkspaceInputs,
upsert: true,
},
__scalar: true,
},
});
return { syncedCount: userWorkspaces.length };
};
@@ -0,0 +1,268 @@
import { enumCloudUser2ActivityStatusEnum } from 'twenty-sdk/generated';
import { z } from 'zod';
import { getApplicationConfig } from 'src/shared/application-config';
import { fetchFromClickHouse } from 'src/shared/clickhouse-client';
import { clickHouseDateToIso } from 'src/shared/clickhouse-date-to-iso';
import { twentyClient } from 'src/shared/twenty-client';
const clickHouseUserSchema = z.object({
userId: z.uuid(),
firstName: z.string(),
lastName: z.string(),
email: z.email(),
fullName: z.string(),
isEmailVerified: z.boolean(),
disabled: z.boolean(),
canImpersonate: z.boolean(),
canAccessFullAdminPanel: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string(),
locale: z.string(),
createdDate: z.string(),
workspaceCount: z.coerce.number(),
workspaceIds: z.string(),
workspaceDomains: z.string(),
firstActivityDate: z.string(),
lastActivityDate: z.string(),
lastWorkspaceId: z.string(),
totalPageviews: z.coerce.number(),
pageviewsLast30d: z.coerce.number(),
pageviewsLast7d: z.coerce.number(),
pageviewsLast24h: z.coerce.number(),
userAgeDays: z.coerce.number(),
daysSinceLastActivity: z.coerce.number(),
isActiveLast30d: z.boolean(),
isActiveLast7d: z.boolean(),
isActiveLast24h: z.boolean(),
activityStatus: z
.string()
.transform((val) => val.toUpperCase())
.pipe(z.enum(enumCloudUser2ActivityStatusEnum)),
avgDailyPageviewsLast30d: z.coerce.number(),
isTwenty: z.coerce.boolean(),
maxWorkspaceMembers: z.coerce.number(),
inTrial: z.boolean(),
});
type ClickHouseUser = z.infer<typeof clickHouseUserSchema>;
const fetchUsersFromClickHouse = async (): Promise<ClickHouseUser[]> => {
const { clickHouseDatabase } = getApplicationConfig();
// const nowDate = 'now()'
const nowDate = "'2026-02-04 14:28:52.000'";
const query = `
SELECT
*
FROM
${clickHouseDatabase}.user
WHERE
lastActivityDate >= ${nowDate} - INTERVAL 500 MINUTE
AND
lastActivityDate <= ${nowDate}
FORMAT
JSONEachRow;
`;
return fetchFromClickHouse(query, clickHouseUserSchema);
};
const fetchAllPeopleFromTwentyByEmail = async (emails: string[]) => {
if (emails.length === 0) {
return [];
}
const allPeople = await twentyClient.query({
people: {
edges: {
node: {
id: true,
emails: {
primaryEmail: true,
},
},
},
__args: {
filter: {
emails: {
primaryEmail: {
in: emails,
},
},
},
},
},
});
return allPeople.people?.edges.map((edge) => edge.node) ?? [];
};
const buildCloudUserInput = ({
user,
personId,
}: {
user: ClickHouseUser;
personId: string;
}) => ({
id: user.userId,
name: user.fullName,
email: {
primaryEmail: user.email,
},
personId,
fullName: {
lastName: user.lastName,
firstName: user.firstName,
},
isTwenty: user.isTwenty,
userTenure: user.userAgeDays,
isActiveL7d: user.isActiveLast7d,
isActiveL24h: user.isActiveLast24h,
isActiveL30d: user.isActiveLast30d,
pageViewsL7d: user.pageviewsLast7d,
pageViewsL24h: user.pageviewsLast24h,
pageViewsL30d: user.pageviewsLast30d,
activityStatus: user.activityStatus,
workspaceCount: user.workspaceCount,
lastActivityDate: clickHouseDateToIso(user.lastActivityDate),
dataLastUpdatedAt: new Date().toISOString(),
avgDailyPageviewsLast30d: user.avgDailyPageviewsLast30d,
daysSinceLastActivity: user.daysSinceLastActivity,
});
export const syncCloudUsers = async (): Promise<{
syncedCount: number;
}> => {
const users = await fetchUsersFromClickHouse();
console.log('Fetched users from ClickHouse', users);
const emails = users.map((user) => user.email);
console.log('Fetching people from Twenty with emails', emails);
const people = await fetchAllPeopleFromTwentyByEmail(emails);
console.log('Fetched people from Twenty', people);
// Build a map of email -> personId from existing people
const emailToPersonId = new Map<string, string>();
for (const person of people) {
if (person.emails?.primaryEmail !== undefined) {
emailToPersonId.set(person.emails.primaryEmail.toLowerCase(), person.id);
}
}
// Partition users into those with/without an existing person
const usersWithoutPerson = users.filter(
(user) => !emailToPersonId.has(user.email.toLowerCase()),
);
// Step 1: Batch-create missing people
const newlyCreatedPersonIds: string[] = [];
if (usersWithoutPerson.length > 0) {
console.log(`Batch-creating ${usersWithoutPerson.length} people records`);
const createPeopleResult = await twentyClient.mutation({
createPeople: {
__args: {
data: usersWithoutPerson.map((user) => ({
name: {
firstName: user.firstName,
lastName: user.lastName,
},
emails: {
primaryEmail: user.email,
},
})),
},
id: true,
emails: {
primaryEmail: true,
},
},
});
const createdPeople = createPeopleResult.createPeople ?? [];
for (const person of createdPeople) {
if (
person.emails?.primaryEmail === undefined ||
person.id === undefined
) {
continue;
}
newlyCreatedPersonIds.push(person.id);
emailToPersonId.set(person.emails.primaryEmail.toLowerCase(), person.id);
}
}
// Step 2: Batch-upsert all cloud users, with rollback on failure
const cloudUserInputs = users.map((user) => {
const personId = emailToPersonId.get(user.email.toLowerCase());
if (personId === undefined) {
throw new Error(
`No personId found for user ${user.email} — this should not happen`,
);
}
return buildCloudUserInput({ user, personId });
});
try {
console.log(`Batch-upserting ${cloudUserInputs.length} cloud users`);
await twentyClient.mutation({
createCloudUsers2: {
__args: {
data: cloudUserInputs,
upsert: true,
},
__scalar: true,
},
});
} catch (cloudUserError) {
console.log(
'Cloud user upsert failed, rolling back newly created people',
cloudUserError,
);
// Rollback: hard-delete people that were created in step 1
if (newlyCreatedPersonIds.length > 0) {
try {
await twentyClient.mutation({
destroyPeople: {
__args: {
filter: {
id: {
in: newlyCreatedPersonIds,
},
},
},
id: true,
},
});
console.log(
`Rolled back ${newlyCreatedPersonIds.length} newly created people`,
);
} catch (rollbackError) {
console.log(
'Rollback of newly created people also failed',
rollbackError,
);
}
}
throw cloudUserError;
}
return { syncedCount: users.length };
};
@@ -0,0 +1,201 @@
import {
enumCloudWorkspace2ActivationStatusEnum,
enumCloudWorkspace2PaymentFrequencyEnum,
enumCloudWorkspace2SubscriptionStatusEnum,
} from 'twenty-sdk/generated';
import { z } from 'zod';
import { getApplicationConfig } from 'src/shared/application-config';
import { fetchFromClickHouse } from 'src/shared/clickhouse-client';
import { clickHouseDateToIso } from 'src/shared/clickhouse-date-to-iso';
import { twentyClient } from 'src/shared/twenty-client';
const clickHouseWorkspaceSchema = z.object({
workspaceId: z.string(),
workspaceName: z.string(),
subdomain: z.string(),
customDomain: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string(),
activationStatus: z
.string()
.transform((val) => {
const upper = val.toUpperCase();
if (upper === '') return 'EMPTY';
const valid: string[] = Object.values(
enumCloudWorkspace2ActivationStatusEnum,
);
return valid.includes(upper) ? upper : 'EMPTY';
})
.pipe(z.enum(enumCloudWorkspace2ActivationStatusEnum)),
createdDate: z.string(),
lastPageviewDate: z.string(),
pageviewsLast30d: z.coerce.number(),
pageviewsLast7d: z.coerce.number(),
pageviewsLast24h: z.coerce.number(),
totalEverActiveUsers: z.coerce.number(),
totalWorkspaceUsers: z.coerce.number(),
activeUsersLast30d: z.coerce.number(),
activeUsersLast7d: z.coerce.number(),
activeUsersLast24h: z.coerce.number(),
isActiveLast30d: z.coerce.boolean(),
isActiveLast7d: z.coerce.boolean(),
isActiveLast24h: z.coerce.boolean(),
activeUserRatioLast30d: z.coerce.number(),
activeUserRatioLast7d: z.coerce.number(),
workspaceAgeDays: z.coerce.number(),
totalEvents: z.coerce.number(),
eventsLast30d: z.coerce.number(),
eventsPerUser: z.coerce.number(),
subscription_status: z
.string()
.transform((val) => {
const upper = val.toUpperCase();
if (upper === '') return 'EMPTY';
const valid: string[] = Object.values(
enumCloudWorkspace2SubscriptionStatusEnum,
);
return valid.includes(upper) ? upper : 'OTHER';
})
.pipe(z.enum(enumCloudWorkspace2SubscriptionStatusEnum)),
payment_frequency: z
.string()
.transform((val) => {
const upper = val.toUpperCase();
if (upper === '') return 'EMPTY';
const valid: string[] = Object.values(
enumCloudWorkspace2PaymentFrequencyEnum,
);
return valid.includes(upper) ? upper : 'OTHER';
})
.pipe(z.enum(enumCloudWorkspace2PaymentFrequencyEnum)),
trial_status: z.string(),
mrr: z.coerce.number(),
potential_arr: z.coerce.number(),
arr: z.coerce.number().nullable(),
next_renewal_date: z.string(),
workspace_domain: z.string(),
domain_source: z.string(),
creatorUserId: z.string(),
creatorEmail: z.string(),
creator_domain_type: z.string(),
primary_business_domain: z.string(),
business_domain_user_count: z.coerce.number(),
isTwenty: z.coerce.boolean(),
});
type ClickHouseWorkspace = z.infer<typeof clickHouseWorkspaceSchema>;
const fetchWorkspacesFromClickHouse =
async (): Promise<ClickHouseWorkspace[]> => {
const { clickHouseDatabase } = getApplicationConfig();
// const nowDate = 'now()'
const nowDate = "'2026-02-04 14:28:52.000'";
const query = `
SELECT
*
FROM (
SELECT
*,
row_number() OVER (PARTITION BY workspaceId ORDER BY updatedAt DESC) AS rn
FROM
${clickHouseDatabase}.workspace
WHERE
updatedAt >= ${nowDate} - INTERVAL 500 MINUTE
AND
updatedAt <= ${nowDate}
)
WHERE
rn = 1
FORMAT
JSONEachRow;
`;
return fetchFromClickHouse(query, clickHouseWorkspaceSchema);
};
// Convert a dollar amount to micros (1 USD = 1_000_000 micros).
const dollarsToAmountMicros = (dollars: number) =>
Math.round(dollars * 1_000_000);
const buildCloudWorkspaceInput = (workspace: ClickHouseWorkspace) => ({
id: workspace.workspaceId,
name: workspace.workspaceName,
subDomain: workspace.subdomain,
customDomain: {
primaryLinkUrl: workspace.customDomain,
primaryLinkLabel: '',
},
activationStatus: workspace.activationStatus,
subscriptionStatus: workspace.subscription_status,
paymentFrequency: workspace.payment_frequency,
lastPageViewDate: workspace.lastPageviewDate,
pageViewsL30D: workspace.pageviewsLast30d,
pageViewsL7D: workspace.pageviewsLast7d,
pageViewsL24H: workspace.pageviewsLast24h,
totalEverActiveWorkspaceUsers: workspace.totalEverActiveUsers,
totalWorkspaceUsers: workspace.totalWorkspaceUsers,
activeUsersL30D: workspace.activeUsersLast30d,
activeUsersL7D: workspace.activeUsersLast7d,
activeUsersL24H: workspace.activeUsersLast24h,
isActiveL30D: workspace.isActiveLast30d,
isActiveL7D: workspace.isActiveLast7d,
isActiveL24H: workspace.isActiveLast24h,
workspaceTenure: workspace.workspaceAgeDays,
numberOfEventsTotal: workspace.totalEvents,
numberOfEventsL30D: workspace.eventsLast30d,
mrr: {
amountMicros: dollarsToAmountMicros(workspace.mrr),
currencyCode: 'USD',
},
potentialArr: {
amountMicros: dollarsToAmountMicros(workspace.potential_arr),
currencyCode: 'USD',
},
arr: {
amountMicros: dollarsToAmountMicros(workspace.arr ?? 0),
currencyCode: 'USD',
},
nextRenewalDate: clickHouseDateToIso(workspace.next_renewal_date),
creatorEmail: {
primaryEmail: workspace.creatorEmail,
},
workspaceBusinessDomain: {
primaryLinkUrl: workspace.primary_business_domain,
primaryLinkLabel: '',
},
dataLastUpdatedAt: new Date().toISOString(),
});
export const syncCloudWorkspaces = async (): Promise<{
syncedCount: number;
}> => {
const workspaces = await fetchWorkspacesFromClickHouse();
console.log(`Fetched ${workspaces.length} workspaces from ClickHouse`);
if (workspaces.length === 0) {
return { syncedCount: 0 };
}
const cloudWorkspaceInputs = workspaces.map(buildCloudWorkspaceInput);
console.log(
`Batch-upserting ${cloudWorkspaceInputs.length} cloud workspaces`,
);
await twentyClient.mutation({
createCloudWorkspaces2: {
__args: {
data: cloudWorkspaceInputs,
upsert: true,
},
__scalar: true,
},
});
return { syncedCount: workspaces.length };
};
@@ -0,0 +1,11 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { ALL_CLOUD_USER_2_VIEW_ID } from 'src/views/all-cloud-user-2';
export default defineNavigationMenuItem({
universalIdentifier: 'ac6df084-0f0f-404a-b3d1-b084cb624d76',
name: 'cloud-user-2',
icon: 'IconList',
position: 0,
viewUniversalIdentifier: ALL_CLOUD_USER_2_VIEW_ID,
});
@@ -0,0 +1,11 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { ALL_CLOUD_USER_WORKSPACE_2_VIEW_ID } from 'src/views/all-cloud-user-workspace-2';
export default defineNavigationMenuItem({
universalIdentifier: 'e29251f0-78ad-493f-8f99-ec54ab0b5378',
name: 'cloud-user-workspace-2',
icon: 'IconList',
position: 0,
viewUniversalIdentifier: ALL_CLOUD_USER_WORKSPACE_2_VIEW_ID,
});
@@ -0,0 +1,11 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { ALL_CLOUD_WORKSPACE_2_VIEW_ID } from 'src/views/all-cloud-workspace-2';
export default defineNavigationMenuItem({
universalIdentifier: '0ba6315e-0d79-47a5-8c05-29dec621a647',
name: 'cloud-workspace-2',
icon: 'IconList',
position: 0,
viewUniversalIdentifier: ALL_CLOUD_WORKSPACE_2_VIEW_ID,
});
@@ -0,0 +1,171 @@
import { defineObject, FieldType } from 'twenty-sdk';
import {
CLOUD_USER_2_ACTIVITY_STATUS_FIELD_ID,
CLOUD_USER_2_AVG_DAILY_PAGEVIEWS_LAST_30D_FIELD_ID,
CLOUD_USER_2_DATA_LAST_UPDATED_AT_FIELD_ID,
CLOUD_USER_2_DAYS_SINCE_LAST_ACTIVITY_FIELD_ID,
CLOUD_USER_2_EMAIL_FIELD_ID,
CLOUD_USER_2_FULL_NAME_FIELD_ID,
CLOUD_USER_2_IS_ACTIVE_L24H_FIELD_ID,
CLOUD_USER_2_IS_ACTIVE_L30D_FIELD_ID,
CLOUD_USER_2_IS_ACTIVE_L7D_FIELD_ID,
CLOUD_USER_2_IS_TWENTY_FIELD_ID,
CLOUD_USER_2_LAST_ACTIVITY_DATE_FIELD_ID,
CLOUD_USER_2_PAGE_VIEWS_L24H_FIELD_ID,
CLOUD_USER_2_PAGE_VIEWS_L30D_FIELD_ID,
CLOUD_USER_2_PAGE_VIEWS_L7D_FIELD_ID,
CLOUD_USER_2_UPDATED_BY_FIELD_ID,
CLOUD_USER_2_USER_TENURE_FIELD_ID,
CLOUD_USER_2_WORKSPACE_COUNT_FIELD_ID,
} from 'src/fields/cloud-user-2-field-ids';
export const CLOUD_USER_2_UNIVERSAL_IDENTIFIER =
'da264c72-df22-49b3-98e3-21cf6013e671';
export default defineObject({
universalIdentifier: CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
nameSingular: 'cloudUser2',
namePlural: 'cloudUsers2',
labelSingular: 'Cloud user 2',
labelPlural: 'Cloud users 2',
icon: 'IconBox',
fields: [
{
universalIdentifier: CLOUD_USER_2_ACTIVITY_STATUS_FIELD_ID,
type: FieldType.SELECT,
name: 'activityStatus',
label: 'Activity Status',
options: [
{
id: '141d21f3-71b0-4b37-a3d2-5d91d43d0493',
value: 'ACTIVE',
label: 'Active',
position: 0,
color: 'green',
},
{
id: 'adb1d290-750f-468e-99ee-ed892bcb8974',
value: 'RECENT',
label: 'Recent',
position: 1,
color: 'blue',
},
{
id: '96f24b75-a671-4727-a78f-5a72419370ea',
value: 'DORMANT',
label: 'Dormant',
position: 2,
color: 'orange',
},
{
id: '88ddbef9-16a8-4643-9726-5b271cd477fa',
value: 'INACTIVE',
label: 'Inactive',
position: 3,
color: 'blue',
},
],
},
{
universalIdentifier: CLOUD_USER_2_AVG_DAILY_PAGEVIEWS_LAST_30D_FIELD_ID,
type: FieldType.NUMBER,
name: 'avgDailyPageviewsLast30d',
label: 'Avg Daily Page Views',
},
{
universalIdentifier: CLOUD_USER_2_DATA_LAST_UPDATED_AT_FIELD_ID,
type: FieldType.DATE_TIME,
name: 'dataLastUpdatedAt',
label: 'Data last updated at',
},
{
universalIdentifier: CLOUD_USER_2_DAYS_SINCE_LAST_ACTIVITY_FIELD_ID,
type: FieldType.NUMBER,
name: 'daysSinceLastActivity',
label: 'Days Since Last Activity',
},
{
universalIdentifier: CLOUD_USER_2_EMAIL_FIELD_ID,
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
},
{
universalIdentifier: CLOUD_USER_2_FULL_NAME_FIELD_ID,
type: FieldType.FULL_NAME,
name: 'fullName',
label: 'Full Name',
},
{
universalIdentifier: CLOUD_USER_2_IS_ACTIVE_L24H_FIELD_ID,
type: FieldType.BOOLEAN,
name: 'isActiveL24h',
label: 'Is Active L24H',
defaultValue: true
},
{
universalIdentifier: CLOUD_USER_2_IS_ACTIVE_L30D_FIELD_ID,
type: FieldType.BOOLEAN,
name: 'isActiveL30d',
label: 'Is Active L30D',
defaultValue: false
},
{
universalIdentifier: CLOUD_USER_2_IS_ACTIVE_L7D_FIELD_ID,
type: FieldType.BOOLEAN,
name: 'isActiveL7d',
label: 'Is Active L7D',
defaultValue: true
},
{
universalIdentifier: CLOUD_USER_2_IS_TWENTY_FIELD_ID,
type: FieldType.BOOLEAN,
name: 'isTwenty',
label: 'Is Twenty',
defaultValue: false
},
{
universalIdentifier: CLOUD_USER_2_LAST_ACTIVITY_DATE_FIELD_ID,
type: FieldType.DATE_TIME,
name: 'lastActivityDate',
label: 'Last Activity Date',
},
{
universalIdentifier: CLOUD_USER_2_PAGE_VIEWS_L24H_FIELD_ID,
type: FieldType.NUMBER,
name: 'pageViewsL24h',
label: 'Page Views L24H',
},
{
universalIdentifier: CLOUD_USER_2_PAGE_VIEWS_L30D_FIELD_ID,
type: FieldType.NUMBER,
name: 'pageViewsL30d',
label: 'Page Views L30D',
},
{
universalIdentifier: CLOUD_USER_2_PAGE_VIEWS_L7D_FIELD_ID,
type: FieldType.NUMBER,
name: 'pageViewsL7d',
label: 'Page Views L7D',
},
{
universalIdentifier: CLOUD_USER_2_UPDATED_BY_FIELD_ID,
type: FieldType.ACTOR,
name: 'updatedBy',
label: 'Updated by',
},
{
universalIdentifier: CLOUD_USER_2_USER_TENURE_FIELD_ID,
type: FieldType.NUMBER,
name: 'userTenure',
label: 'User tenure',
},
{
universalIdentifier: CLOUD_USER_2_WORKSPACE_COUNT_FIELD_ID,
type: FieldType.NUMBER,
name: 'workspaceCount',
label: 'Workspace count',
},
],
});
@@ -0,0 +1,67 @@
import { defineObject, FieldType } from 'twenty-sdk';
import {
CLOUD_USER_WORKSPACE_2_ID_OF_THE_USER_WORKSPACE_FIELD_ID,
CLOUD_USER_WORKSPACE_2_TWENTY_USER_IDENTIFIER_FIELD_ID,
CLOUD_USER_WORKSPACE_2_TWENTY_WORKSPACE_IDENTIFIER_FIELD_ID,
CLOUD_USER_WORKSPACE_2_UPDATED_BY_FIELD_ID,
} from 'src/fields/cloud-user-workspace-2-field-ids';
export const CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER =
'14d6e1f4-c513-4766-9210-bc5dc8294e51';
export default defineObject({
universalIdentifier: CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
nameSingular: 'cloudUserWorkspace2',
namePlural: 'cloudUserWorkspaces2',
labelSingular: 'Cloud user workspace 2',
labelPlural: 'Cloud user workspaces 2',
icon: 'IconBox',
fields: [
{
universalIdentifier: CLOUD_USER_WORKSPACE_2_TWENTY_USER_IDENTIFIER_FIELD_ID,
type: FieldType.TEXT,
name: 'twentyUserIdentifier',
label: 'Twenty User Identifier',
icon: 'IconMan',
isNullable: true,
defaultValue: null,
universalSettings: {
displayedMaxRows: 0,
},
},
{
universalIdentifier: CLOUD_USER_WORKSPACE_2_TWENTY_WORKSPACE_IDENTIFIER_FIELD_ID,
type: FieldType.TEXT,
name: 'twentyWorkspaceIdentifier',
label: 'Twenty Workspace Identifier',
icon: 'IconScreenShare',
isNullable: true,
defaultValue: null,
universalSettings: {
displayedMaxRows: 0,
},
},
{
universalIdentifier: CLOUD_USER_WORKSPACE_2_ID_OF_THE_USER_WORKSPACE_FIELD_ID,
type: FieldType.TEXT,
name: 'idOfTheUserWorkspace',
label: 'Id of the user workspace',
icon: 'IconTypography',
isNullable: true,
defaultValue: null,
universalSettings: {
displayedMaxRows: 0,
},
},
{
universalIdentifier: CLOUD_USER_WORKSPACE_2_UPDATED_BY_FIELD_ID,
type: FieldType.ACTOR,
name: 'updatedBy',
label: 'Updated by',
description: 'The user who last updated the record',
icon: 'IconUserCircle',
isNullable: true,
},
],
});
@@ -0,0 +1,589 @@
import { defineObject, FieldType } from 'twenty-sdk';
import {
CLOUD_WORKSPACE_2_ACTIVATION_STATUS_FIELD_ID,
CLOUD_WORKSPACE_2_ACTIVE_USERS_L24H_FIELD_ID,
CLOUD_WORKSPACE_2_ACTIVE_USERS_L30D_FIELD_ID,
CLOUD_WORKSPACE_2_ACTIVE_USERS_L7D_FIELD_ID,
CLOUD_WORKSPACE_2_ALEXA_RANK_FIELD_ID,
CLOUD_WORKSPACE_2_ANNUAL_REVENUE_FIELD_ID,
CLOUD_WORKSPACE_2_ARR_FIELD_ID,
CLOUD_WORKSPACE_2_COMPANY_FOUNDED_YEAR_FIELD_ID,
CLOUD_WORKSPACE_2_COMPANY_LINKEDIN_FIELD_ID,
CLOUD_WORKSPACE_2_COMPANY_NAME_FIELD_ID,
CLOUD_WORKSPACE_2_CREATOR_EMAIL_FIELD_ID,
CLOUD_WORKSPACE_2_CUSTOM_DOMAIN_FIELD_ID,
CLOUD_WORKSPACE_2_DATA_LAST_UPDATED_AT_FIELD_ID,
CLOUD_WORKSPACE_2_DESCRIPTION_FIELD_ID,
CLOUD_WORKSPACE_2_EMPLOYEES_FIELD_ID,
CLOUD_WORKSPACE_2_INDUSTRY_FIELD_ID,
CLOUD_WORKSPACE_2_IS_ACTIVE_L24H_FIELD_ID,
CLOUD_WORKSPACE_2_IS_ACTIVE_L30D_FIELD_ID,
CLOUD_WORKSPACE_2_IS_ACTIVE_L7D_FIELD_ID,
CLOUD_WORKSPACE_2_IS_ENRICHED_FIELD_ID,
CLOUD_WORKSPACE_2_LAST_PAGE_VIEW_DATE_FIELD_ID,
CLOUD_WORKSPACE_2_LATEST_FUNDING_STAGE_FIELD_ID,
CLOUD_WORKSPACE_2_MRR_FIELD_ID,
CLOUD_WORKSPACE_2_NEXT_RENEWAL_DATE_FIELD_ID,
CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_L30D_FIELD_ID,
CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_TOTAL_FIELD_ID,
CLOUD_WORKSPACE_2_PAGE_VIEWS_L24H_FIELD_ID,
CLOUD_WORKSPACE_2_PAGE_VIEWS_L30D_FIELD_ID,
CLOUD_WORKSPACE_2_PAGE_VIEWS_L7D_FIELD_ID,
CLOUD_WORKSPACE_2_PAYMENT_FREQUENCY_FIELD_ID,
CLOUD_WORKSPACE_2_POTENTIAL_ARR_FIELD_ID,
CLOUD_WORKSPACE_2_SUB_DOMAIN_FIELD_ID,
CLOUD_WORKSPACE_2_SUBSCRIPTION_STATUS_FIELD_ID,
CLOUD_WORKSPACE_2_TAGS_FIELD_ID,
CLOUD_WORKSPACE_2_TOTAL_EVER_ACTIVE_WORKSPACE_USERS_FIELD_ID,
CLOUD_WORKSPACE_2_TOTAL_FUNDING_FIELD_ID,
CLOUD_WORKSPACE_2_TOTAL_WORKSPACE_USERS_FIELD_ID,
CLOUD_WORKSPACE_2_UPDATED_BY_FIELD_ID,
CLOUD_WORKSPACE_2_WORKSPACE_BUSINESS_DOMAIN_FIELD_ID,
CLOUD_WORKSPACE_2_WORKSPACE_TENURE_FIELD_ID,
} from 'src/fields/cloud-workspace-2-field-ids';
export const CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER =
'77376ed3-19c1-4859-bde5-90f19ad02113';
export default defineObject({
universalIdentifier: CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
nameSingular: 'cloudWorkspace2',
namePlural: 'cloudWorkspaces2',
labelSingular: 'Cloud workspace 2',
labelPlural: 'Cloud workspaces 2',
icon: 'IconBox',
fields: [
{
universalIdentifier: CLOUD_WORKSPACE_2_CUSTOM_DOMAIN_FIELD_ID,
type: FieldType.LINKS,
name: 'customDomain',
label: 'Custom domain',
description: 'Custom domain set up by the customer to use their own.',
icon: 'IconLink',
isNullable: true,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_WORKSPACE_BUSINESS_DOMAIN_FIELD_ID,
type: FieldType.LINKS,
name: 'workspaceBusinessDomain',
label: 'Workspace Business Domain',
icon: 'IconLink',
isNullable: true,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_COMPANY_LINKEDIN_FIELD_ID,
type: FieldType.LINKS,
name: 'companyLinkedin',
label: 'Company LinkedIn',
icon: 'IconBrandLinkedin',
isNullable: true,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_DATA_LAST_UPDATED_AT_FIELD_ID,
type: FieldType.DATE,
name: 'dataLastUpdatedAt',
label: 'Data last updated at',
description: 'by workflow',
icon: 'IconCalendarEvent',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_LAST_PAGE_VIEW_DATE_FIELD_ID,
type: FieldType.DATE,
name: 'lastPageViewDate',
label: 'Last Page View Date',
icon: 'IconCalendarEvent',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_TOTAL_EVER_ACTIVE_WORKSPACE_USERS_FIELD_ID,
type: FieldType.NUMBER,
name: 'totalEverActiveWorkspaceUsers',
label: 'Total ever active workspace users',
icon: 'IconUsers',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_ACTIVE_USERS_L30D_FIELD_ID,
type: FieldType.NUMBER,
name: 'activeUsersL30D',
label: 'Active Users L30D',
icon: 'IconUsersGroup',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_ACTIVE_USERS_L7D_FIELD_ID,
type: FieldType.NUMBER,
name: 'activeUsersL7D',
label: 'Active Users L7D',
icon: 'IconUsersGroup',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_ACTIVE_USERS_L24H_FIELD_ID,
type: FieldType.NUMBER,
name: 'activeUsersL24H',
label: 'Active Users L24H',
icon: 'IconUsersGroup',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_WORKSPACE_TENURE_FIELD_ID,
type: FieldType.NUMBER,
name: 'workspaceTenure',
label: 'Workspace tenure',
description: 'in days',
icon: 'IconCalendarHeart',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_PAGE_VIEWS_L30D_FIELD_ID,
type: FieldType.NUMBER,
name: 'pageViewsL30D',
label: 'Page Views L30D',
icon: 'IconClick',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_PAGE_VIEWS_L7D_FIELD_ID,
type: FieldType.NUMBER,
name: 'pageViewsL7D',
label: 'Page Views L7D',
icon: 'IconClick',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_PAGE_VIEWS_L24H_FIELD_ID,
type: FieldType.NUMBER,
name: 'pageViewsL24H',
label: 'Page Views L24H',
icon: 'IconClick',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_TOTAL_WORKSPACE_USERS_FIELD_ID,
type: FieldType.NUMBER,
name: 'totalWorkspaceUsers',
label: 'Total workspace users',
icon: 'IconUsers',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_L30D_FIELD_ID,
type: FieldType.NUMBER,
name: 'numberOfEventsL30D',
label: 'Number of events L30D',
icon: 'IconHandClick',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_TOTAL_FIELD_ID,
type: FieldType.NUMBER,
name: 'numberOfEventsTotal',
label: 'Number of events total',
icon: 'IconHandClick',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_ALEXA_RANK_FIELD_ID,
type: FieldType.NUMBER,
name: 'alexaRank',
label: 'Alexa Rank',
icon: 'IconNumber9',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_EMPLOYEES_FIELD_ID,
type: FieldType.NUMBER,
name: 'employees',
label: 'Employees',
icon: 'IconUsers',
isNullable: true,
defaultValue: null,
universalSettings: {
type: 'number',
decimals: 0,
},
},
{
universalIdentifier: CLOUD_WORKSPACE_2_IS_ACTIVE_L7D_FIELD_ID,
type: FieldType.BOOLEAN,
name: 'isActiveL7D',
label: 'Is Active L7D',
icon: 'IconActivity',
isNullable: true,
defaultValue: false,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_IS_ACTIVE_L24H_FIELD_ID,
type: FieldType.BOOLEAN,
name: 'isActiveL24H',
label: 'Is Active L24H',
icon: 'IconActivity',
isNullable: true,
defaultValue: true,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_IS_ACTIVE_L30D_FIELD_ID,
type: FieldType.BOOLEAN,
name: 'isActiveL30D',
label: 'Is Active L30D',
icon: 'IconActivity',
isNullable: true,
defaultValue: false,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_IS_ENRICHED_FIELD_ID,
type: FieldType.BOOLEAN,
name: 'isEnriched',
label: 'Is Enriched',
icon: 'IconToggleLeft',
isNullable: true,
defaultValue: false,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_INDUSTRY_FIELD_ID,
type: FieldType.TEXT,
name: 'industry',
label: 'Industry',
icon: 'IconWorldLongitude',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_COMPANY_FOUNDED_YEAR_FIELD_ID,
type: FieldType.TEXT,
name: 'companyFoundedYear',
label: 'Company Founded Year',
icon: 'IconCalendarPlus',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_DESCRIPTION_FIELD_ID,
type: FieldType.TEXT,
name: 'description',
label: 'Description',
icon: 'IconBaselineDensitySmall',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_LATEST_FUNDING_STAGE_FIELD_ID,
type: FieldType.TEXT,
name: 'latestFundingStage',
label: 'Latest Funding Stage',
icon: 'IconBellDollar',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_COMPANY_NAME_FIELD_ID,
type: FieldType.TEXT,
name: 'companyName',
label: 'Company Name',
icon: 'IconTypography',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_SUB_DOMAIN_FIELD_ID,
type: FieldType.TEXT,
name: 'subDomain',
label: 'Sub domain',
description:
'This is the subdomain chosen by customers to access their workspace',
icon: 'IconLink',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_ANNUAL_REVENUE_FIELD_ID,
type: FieldType.CURRENCY,
name: 'annualRevenue',
label: 'Annual Revenue',
icon: 'IconCurrencyDollar',
isNullable: true,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_MRR_FIELD_ID,
type: FieldType.CURRENCY,
name: 'mrr',
label: 'MRR',
icon: 'IconMoneybag',
isNullable: true,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_POTENTIAL_ARR_FIELD_ID,
type: FieldType.CURRENCY,
name: 'potentialArr',
label: 'Potential ARR',
icon: 'IconMoneybag',
isNullable: true,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_ARR_FIELD_ID,
type: FieldType.CURRENCY,
name: 'arr',
label: 'ARR',
icon: 'IconMoneybag',
isNullable: true,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_TOTAL_FUNDING_FIELD_ID,
type: FieldType.CURRENCY,
name: 'totalFunding',
label: 'Total Funding',
icon: 'IconMoneybag',
isNullable: true,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_ACTIVATION_STATUS_FIELD_ID,
type: FieldType.SELECT,
name: 'activationStatus',
label: 'Activation Status',
icon: 'IconTag',
isNullable: true,
defaultValue: null,
options: [
{
id: 'c41ee49c-e629-431e-8601-183726f93315',
color: 'yellow',
label: 'Pending creation',
value: 'PENDING_CREATION',
position: 0,
},
{
id: 'cb82ad14-5fcb-4ed0-af58-c92f84b45d2b',
color: 'gray',
label: 'Suspended',
value: 'SUSPENDED',
position: 1,
},
{
id: 'b1084bc8-0efd-44ce-974c-7fb44c310be5',
color: 'green',
label: 'Active',
value: 'ACTIVE',
position: 2,
},
{
id: 'b5b33c8e-eb3f-4758-99c9-d2f39c011b96',
color: 'blue',
label: 'Ongoing creation',
value: 'ONGOING_CREATION',
position: 3,
},
{
id: '2ae41a69-c521-4545-9e0b-f1f941ceee83',
color: 'purple',
label: 'Empty',
value: 'EMPTY',
position: 4,
},
],
},
{
universalIdentifier: CLOUD_WORKSPACE_2_SUBSCRIPTION_STATUS_FIELD_ID,
type: FieldType.SELECT,
name: 'subscriptionStatus',
label: 'Subscription Status',
icon: 'IconTag',
isNullable: true,
defaultValue: null,
options: [
{
id: 'b5ee9a91-0a15-4cbe-a36e-198f5bae431d',
color: 'green',
label: 'Active',
value: 'ACTIVE',
position: 0,
},
{
id: '78837243-38d9-44d4-8b9c-a6c0ca082ba8',
color: 'blue',
label: 'Trialing',
value: 'TRIALING',
position: 1,
},
{
id: '6b27ee33-3c30-4694-a607-19264765b11c',
color: 'gray',
label: 'Canceled',
value: 'CANCELED',
position: 2,
},
{
id: '05118231-18b6-4589-be23-8f1368c0182f',
color: 'yellow',
label: 'Other',
value: 'OTHER',
position: 3,
},
{
id: 'ea83a7ff-4fe0-4c56-88f8-cf0bdf76cb08',
color: 'gray',
label: 'Empty',
value: 'EMPTY',
position: 4,
},
],
},
{
universalIdentifier: CLOUD_WORKSPACE_2_PAYMENT_FREQUENCY_FIELD_ID,
type: FieldType.SELECT,
name: 'paymentFrequency',
label: 'Payment Frequency',
icon: 'IconCurrencyDollar',
isNullable: true,
defaultValue: null,
options: [
{
id: 'fa97d4fb-12bf-4cf4-b252-14b1c657d3b6',
color: 'sky',
label: 'Month',
value: 'MONTH',
position: 0,
},
{
id: 'c09c3f1d-31e7-4b29-969a-a4304f04abe0',
color: 'purple',
label: 'Year',
value: 'YEAR',
position: 1,
},
{
id: 'a91d77ed-90ad-41fc-83ae-e688c981abb2',
color: 'gray',
label: 'Other',
value: 'OTHER',
position: 2,
},
{
id: '5c595203-1b33-430b-abd8-8bc80aeedbd7',
color: 'red',
label: 'Empty',
value: 'EMPTY',
position: 3,
},
],
},
{
universalIdentifier: CLOUD_WORKSPACE_2_NEXT_RENEWAL_DATE_FIELD_ID,
type: FieldType.DATE_TIME,
name: 'nextRenewalDate',
label: 'Next Renewal Date',
icon: 'IconCalendarClock',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_CREATOR_EMAIL_FIELD_ID,
type: FieldType.EMAILS,
name: 'creatorEmail',
label: 'Creator Email',
icon: 'IconMail',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_TAGS_FIELD_ID,
type: FieldType.ARRAY,
name: 'tags',
label: 'Tags',
icon: 'IconBracketsContain',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: CLOUD_WORKSPACE_2_UPDATED_BY_FIELD_ID,
type: FieldType.ACTOR,
name: 'updatedBy',
label: 'Updated by',
description: 'The user who last updated the record',
icon: 'IconUserCircle',
isNullable: true,
},
],
});
@@ -0,0 +1,14 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'4bd740cc-f6a1-40c5-b46b-e2a4dbf5af04';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Twenty for Twenty default function role',
description: 'Twenty for Twenty default function role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -0,0 +1,25 @@
import { z } from 'zod';
const applicationConfigSchema = z
.object({
CLICKHOUSE_DATABASE: z.string().nonempty(),
CLICKHOUSE_URL: z.url(),
CLICKHOUSE_USERNAME: z.string().nonempty(),
CLICKHOUSE_PASSWORD: z.string().nonempty(),
});
export const getApplicationConfig = () => {
const env = applicationConfigSchema.parse({
CLICKHOUSE_DATABASE: process.env.CLICKHOUSE_DATABASE,
CLICKHOUSE_URL: process.env.CLICKHOUSE_URL,
CLICKHOUSE_USERNAME: process.env.CLICKHOUSE_USERNAME,
CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD,
});
return {
clickHouseDatabase: env.CLICKHOUSE_DATABASE,
clickHouseUrl: env.CLICKHOUSE_URL,
clickHouseUsername: env.CLICKHOUSE_USERNAME,
clickHousePassword: env.CLICKHOUSE_PASSWORD,
};
};
@@ -0,0 +1,43 @@
import { z } from 'zod';
import { getApplicationConfig } from 'src/shared/application-config';
// Fetches rows from ClickHouse using a raw SQL query,
// parses the JSONEachRow response, and validates each row against the provided Zod schema.
export const fetchFromClickHouse = async <T>(
query: string,
schema: z.ZodType<T>,
): Promise<T[]> => {
const { clickHouseUrl, clickHouseUsername, clickHousePassword } =
getApplicationConfig();
const response = await fetch(clickHouseUrl, {
method: 'POST',
headers: {
Authorization:
'Basic ' +
Buffer.from(`${clickHouseUsername}:${clickHousePassword}`).toString(
'base64',
),
'Content-Type': 'text/plain',
},
body: query,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`ClickHouse error: ${response.status} - ${errorText}`);
}
// Format is a list of JSON objects, one per line (JSONEachRow format).
const text = await response.text();
const rows = text
.trim()
.split('\n')
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
return z.array(schema).parse(rows);
};
@@ -0,0 +1,16 @@
// ClickHouse returns DateTime64 values in the format 'YYYY-MM-DD HH:mm:ss.SSSSSS'
// (space-separated, up to 6 fractional digits). Twenty's DATE_TIME fields expect
// ISO 8601 format ('YYYY-MM-DDTHH:mm:ss.SSSZ', at most 3 fractional digits).
//
// Additionally, ClickHouse uses '1970-01-01 00:00:00.000000' as a sentinel for
// null/empty dates. We map that to null.
const CLICKHOUSE_EPOCH_SENTINEL = '1970-01-01 00:00:00.000000';
export const clickHouseDateToIso = (value: string): string | null => {
if (!value || value === CLICKHOUSE_EPOCH_SENTINEL) {
return null;
}
return new Date(value.replace(' ', 'T') + 'Z').toISOString();
};
@@ -0,0 +1,3 @@
import Twenty from 'twenty-sdk/generated';
export const twentyClient = new Twenty();
@@ -0,0 +1,159 @@
import { defineView } from 'twenty-sdk';
import { ViewType } from 'twenty-shared/types';
import {
CLOUD_USER_2_ACTIVITY_STATUS_FIELD_ID,
CLOUD_USER_2_AVG_DAILY_PAGEVIEWS_LAST_30D_FIELD_ID,
CLOUD_USER_2_DATA_LAST_UPDATED_AT_FIELD_ID,
CLOUD_USER_2_DAYS_SINCE_LAST_ACTIVITY_FIELD_ID,
CLOUD_USER_2_EMAIL_FIELD_ID,
CLOUD_USER_2_FULL_NAME_FIELD_ID,
CLOUD_USER_2_IS_ACTIVE_L24H_FIELD_ID,
CLOUD_USER_2_IS_ACTIVE_L30D_FIELD_ID,
CLOUD_USER_2_IS_ACTIVE_L7D_FIELD_ID,
CLOUD_USER_2_IS_TWENTY_FIELD_ID,
CLOUD_USER_2_LAST_ACTIVITY_DATE_FIELD_ID,
CLOUD_USER_2_PAGE_VIEWS_L24H_FIELD_ID,
CLOUD_USER_2_PAGE_VIEWS_L30D_FIELD_ID,
CLOUD_USER_2_PAGE_VIEWS_L7D_FIELD_ID,
CLOUD_USER_2_UPDATED_BY_FIELD_ID,
CLOUD_USER_2_USER_TENURE_FIELD_ID,
CLOUD_USER_2_WORKSPACE_COUNT_FIELD_ID,
} from 'src/fields/cloud-user-2-field-ids';
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
export const ALL_CLOUD_USER_2_VIEW_ID = 'd6137e11-dbcd-4824-85d3-42fe1ca48cb6';
export default defineView({
universalIdentifier: ALL_CLOUD_USER_2_VIEW_ID,
name: 'all-cloud-user-2',
objectUniversalIdentifier: CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
type: ViewType.TABLE,
fields: [
{
universalIdentifier: '9e82305e-9fd9-4bf7-8cc5-098eb35a748e',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_FULL_NAME_FIELD_ID,
position: 0,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'ea737839-bcb7-4da1-aaf6-79cda65e6263',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_EMAIL_FIELD_ID,
position: 1,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'ccd1eca7-c851-4e97-9f0e-48c7139f3ced',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_ACTIVITY_STATUS_FIELD_ID,
position: 2,
isVisible: true,
size: 130,
},
{
universalIdentifier: 'e99c82d3-d2fe-4758-9253-ab2ca2b50ef8',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_IS_TWENTY_FIELD_ID,
position: 3,
isVisible: true,
size: 100,
},
{
universalIdentifier: 'c03da046-30b6-402e-8d4b-10799aa5102d',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_WORKSPACE_COUNT_FIELD_ID,
position: 4,
isVisible: true,
size: 130,
},
{
universalIdentifier: 'e3a787f2-77d1-4865-a5c2-c60976809d4d',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_USER_TENURE_FIELD_ID,
position: 5,
isVisible: true,
size: 110,
},
{
universalIdentifier: '352d77f2-726b-4a75-8c9d-3117cfabedc1',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_IS_ACTIVE_L24H_FIELD_ID,
position: 6,
isVisible: true,
size: 120,
},
{
universalIdentifier: 'cf06d6b0-e425-4daf-8a6d-9b66a058ae80',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_IS_ACTIVE_L7D_FIELD_ID,
position: 7,
isVisible: true,
size: 120,
},
{
universalIdentifier: '4ab63729-0b48-4b0b-ac9e-e72f5448998c',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_IS_ACTIVE_L30D_FIELD_ID,
position: 8,
isVisible: true,
size: 120,
},
{
universalIdentifier: '8c6a3315-fa47-4a12-b9fe-1775462c803d',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_PAGE_VIEWS_L24H_FIELD_ID,
position: 9,
isVisible: true,
size: 130,
},
{
universalIdentifier: '89011d63-e670-498e-bc8f-142f7ceafd04',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_PAGE_VIEWS_L7D_FIELD_ID,
position: 10,
isVisible: true,
size: 130,
},
{
universalIdentifier: '88816c57-aa5b-4efe-b53a-b288fad24ab4',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_PAGE_VIEWS_L30D_FIELD_ID,
position: 11,
isVisible: true,
size: 130,
},
{
universalIdentifier: '3b0ff937-1eaf-4589-80ec-23abb4f2e204',
fieldMetadataUniversalIdentifier:
CLOUD_USER_2_AVG_DAILY_PAGEVIEWS_LAST_30D_FIELD_ID,
position: 12,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'dee5f679-9ad9-4bc4-94a9-af28d7210699',
fieldMetadataUniversalIdentifier:
CLOUD_USER_2_DAYS_SINCE_LAST_ACTIVITY_FIELD_ID,
position: 13,
isVisible: true,
size: 150,
},
{
universalIdentifier: '25b4e175-be18-4b20-9c51-5db0a596d968',
fieldMetadataUniversalIdentifier:
CLOUD_USER_2_LAST_ACTIVITY_DATE_FIELD_ID,
position: 14,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'f1706c21-d164-414c-9cf5-4cf376584bb9',
fieldMetadataUniversalIdentifier:
CLOUD_USER_2_DATA_LAST_UPDATED_AT_FIELD_ID,
position: 15,
isVisible: true,
size: 150,
},
{
universalIdentifier: '88d0f343-42c1-4d36-82df-cfdb74e785eb',
fieldMetadataUniversalIdentifier: CLOUD_USER_2_UPDATED_BY_FIELD_ID,
position: 16,
isVisible: true,
size: 150,
},
],
});
@@ -0,0 +1,56 @@
import { defineView } from 'twenty-sdk';
import { ViewType } from 'twenty-shared/types';
import {
CLOUD_USER_WORKSPACE_2_ID_OF_THE_USER_WORKSPACE_FIELD_ID,
CLOUD_USER_WORKSPACE_2_TWENTY_USER_IDENTIFIER_FIELD_ID,
CLOUD_USER_WORKSPACE_2_TWENTY_WORKSPACE_IDENTIFIER_FIELD_ID,
CLOUD_USER_WORKSPACE_2_UPDATED_BY_FIELD_ID,
} from 'src/fields/cloud-user-workspace-2-field-ids';
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
export const ALL_CLOUD_USER_WORKSPACE_2_VIEW_ID =
'f80e44ef-43fb-409e-a003-decadaa38b3e';
export default defineView({
universalIdentifier: ALL_CLOUD_USER_WORKSPACE_2_VIEW_ID,
name: 'all-cloud-user-workspace-2',
objectUniversalIdentifier: CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
type: ViewType.TABLE,
fields: [
{
universalIdentifier: '4a0d9d93-d182-443d-8a70-3a4f25e51c50',
fieldMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACE_2_TWENTY_USER_IDENTIFIER_FIELD_ID,
position: 0,
isVisible: true,
size: 200,
},
{
universalIdentifier: '14b78fc4-a6a5-471b-ba87-6aebc9a3da68',
fieldMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACE_2_TWENTY_WORKSPACE_IDENTIFIER_FIELD_ID,
position: 1,
isVisible: true,
size: 220,
},
{
universalIdentifier: '8c9c8a1b-d511-4706-afe7-32b22cce2e7e',
fieldMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACE_2_ID_OF_THE_USER_WORKSPACE_FIELD_ID,
position: 2,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'ee35eded-4222-4a87-b31c-69e415e331db',
fieldMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACE_2_UPDATED_BY_FIELD_ID,
position: 3,
isVisible: true,
size: 150,
},
],
});
@@ -0,0 +1,368 @@
import { defineView } from 'twenty-sdk';
import { ViewType } from 'twenty-shared/types';
import {
CLOUD_WORKSPACE_2_ACTIVATION_STATUS_FIELD_ID,
CLOUD_WORKSPACE_2_ACTIVE_USERS_L24H_FIELD_ID,
CLOUD_WORKSPACE_2_ACTIVE_USERS_L30D_FIELD_ID,
CLOUD_WORKSPACE_2_ACTIVE_USERS_L7D_FIELD_ID,
CLOUD_WORKSPACE_2_ALEXA_RANK_FIELD_ID,
CLOUD_WORKSPACE_2_ANNUAL_REVENUE_FIELD_ID,
CLOUD_WORKSPACE_2_ARR_FIELD_ID,
CLOUD_WORKSPACE_2_COMPANY_FOUNDED_YEAR_FIELD_ID,
CLOUD_WORKSPACE_2_COMPANY_LINKEDIN_FIELD_ID,
CLOUD_WORKSPACE_2_COMPANY_NAME_FIELD_ID,
CLOUD_WORKSPACE_2_CREATOR_EMAIL_FIELD_ID,
CLOUD_WORKSPACE_2_CUSTOM_DOMAIN_FIELD_ID,
CLOUD_WORKSPACE_2_DATA_LAST_UPDATED_AT_FIELD_ID,
CLOUD_WORKSPACE_2_DESCRIPTION_FIELD_ID,
CLOUD_WORKSPACE_2_EMPLOYEES_FIELD_ID,
CLOUD_WORKSPACE_2_INDUSTRY_FIELD_ID,
CLOUD_WORKSPACE_2_IS_ACTIVE_L24H_FIELD_ID,
CLOUD_WORKSPACE_2_IS_ACTIVE_L30D_FIELD_ID,
CLOUD_WORKSPACE_2_IS_ACTIVE_L7D_FIELD_ID,
CLOUD_WORKSPACE_2_IS_ENRICHED_FIELD_ID,
CLOUD_WORKSPACE_2_LAST_PAGE_VIEW_DATE_FIELD_ID,
CLOUD_WORKSPACE_2_LATEST_FUNDING_STAGE_FIELD_ID,
CLOUD_WORKSPACE_2_MRR_FIELD_ID,
CLOUD_WORKSPACE_2_NEXT_RENEWAL_DATE_FIELD_ID,
CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_L30D_FIELD_ID,
CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_TOTAL_FIELD_ID,
CLOUD_WORKSPACE_2_PAGE_VIEWS_L24H_FIELD_ID,
CLOUD_WORKSPACE_2_PAGE_VIEWS_L30D_FIELD_ID,
CLOUD_WORKSPACE_2_PAGE_VIEWS_L7D_FIELD_ID,
CLOUD_WORKSPACE_2_PAYMENT_FREQUENCY_FIELD_ID,
CLOUD_WORKSPACE_2_POTENTIAL_ARR_FIELD_ID,
CLOUD_WORKSPACE_2_SUB_DOMAIN_FIELD_ID,
CLOUD_WORKSPACE_2_SUBSCRIPTION_STATUS_FIELD_ID,
CLOUD_WORKSPACE_2_TAGS_FIELD_ID,
CLOUD_WORKSPACE_2_TOTAL_EVER_ACTIVE_WORKSPACE_USERS_FIELD_ID,
CLOUD_WORKSPACE_2_TOTAL_FUNDING_FIELD_ID,
CLOUD_WORKSPACE_2_TOTAL_WORKSPACE_USERS_FIELD_ID,
CLOUD_WORKSPACE_2_UPDATED_BY_FIELD_ID,
CLOUD_WORKSPACE_2_WORKSPACE_BUSINESS_DOMAIN_FIELD_ID,
CLOUD_WORKSPACE_2_WORKSPACE_TENURE_FIELD_ID,
} from 'src/fields/cloud-workspace-2-field-ids';
import { CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-workspace-2';
export const ALL_CLOUD_WORKSPACE_2_VIEW_ID =
'3747a3a0-25a0-42f0-99d9-61a4b4a76009';
export default defineView({
universalIdentifier: ALL_CLOUD_WORKSPACE_2_VIEW_ID,
name: 'all-cloud-workspace-2',
objectUniversalIdentifier: CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
type: ViewType.TABLE,
fields: [
{
universalIdentifier: '146e3f00-8efc-46be-aafb-a6bafcefa5b0',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_COMPANY_NAME_FIELD_ID,
position: 0,
isVisible: true,
size: 180,
},
{
universalIdentifier: '10ce2149-d25b-4e21-8215-c628677bc4cb',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_SUB_DOMAIN_FIELD_ID,
position: 1,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'c8735db0-9a89-4204-ad58-b68b71c10182',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_DESCRIPTION_FIELD_ID,
position: 2,
isVisible: true,
size: 250,
},
{
universalIdentifier: 'cf3f182b-1a69-4274-a200-ef191400a9e8',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_INDUSTRY_FIELD_ID,
position: 3,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'c932adb5-129b-4457-bbfa-75baaf27707f',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_EMPLOYEES_FIELD_ID,
position: 4,
isVisible: true,
size: 110,
},
{
universalIdentifier: '5f0d33f0-60f0-442c-8c2d-19bfe1c9ea49',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_SUBSCRIPTION_STATUS_FIELD_ID,
position: 5,
isVisible: true,
size: 150,
},
{
universalIdentifier: '904e4ecd-f997-42b6-933a-1174cf2bebdc',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_ACTIVATION_STATUS_FIELD_ID,
position: 6,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'f2d919e7-f782-4edb-ad1a-b99649fbbaf9',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_MRR_FIELD_ID,
position: 7,
isVisible: true,
size: 120,
},
{
universalIdentifier: '92afa154-72f2-4b01-8376-2f5311bb40f9',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_ARR_FIELD_ID,
position: 8,
isVisible: true,
size: 120,
},
{
universalIdentifier: 'c63bcd0a-f8d3-4c86-b9c0-6d638d7e9195',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_POTENTIAL_ARR_FIELD_ID,
position: 9,
isVisible: true,
size: 130,
},
{
universalIdentifier: '8659ca48-23b0-4fb8-b588-0919169cae4d',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_ANNUAL_REVENUE_FIELD_ID,
position: 10,
isVisible: true,
size: 140,
},
{
universalIdentifier: '1524079b-0e7e-41a6-80bc-85bcdfcab537',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_TOTAL_FUNDING_FIELD_ID,
position: 11,
isVisible: true,
size: 130,
},
{
universalIdentifier: '36a755ee-903c-4c61-b091-ebc0e687333d',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_ACTIVE_USERS_L24H_FIELD_ID,
position: 12,
isVisible: true,
size: 140,
},
{
universalIdentifier: '5ac28b38-823f-421c-a177-1749b8283381',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_ACTIVE_USERS_L7D_FIELD_ID,
position: 13,
isVisible: true,
size: 140,
},
{
universalIdentifier: '6fa0bfe5-ac55-4165-8830-5c084466c7f5',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_ACTIVE_USERS_L30D_FIELD_ID,
position: 14,
isVisible: true,
size: 140,
},
{
universalIdentifier: 'bee2ed03-23c2-4c99-86db-c0dd4599c669',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_PAGE_VIEWS_L24H_FIELD_ID,
position: 15,
isVisible: true,
size: 130,
},
{
universalIdentifier: 'e8f9775b-c984-4832-800a-3dd26060c0aa',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_PAGE_VIEWS_L7D_FIELD_ID,
position: 16,
isVisible: true,
size: 130,
},
{
universalIdentifier: '95c4fdfc-8fe5-43ce-9885-3f0fc5b4756a',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_PAGE_VIEWS_L30D_FIELD_ID,
position: 17,
isVisible: true,
size: 130,
},
{
universalIdentifier: '6448c0f1-1469-466e-99ed-c7aa015d9e38',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_TOTAL_WORKSPACE_USERS_FIELD_ID,
position: 18,
isVisible: true,
size: 150,
},
{
universalIdentifier: '082ed200-9bba-444a-9b28-3a129b7ac9d8',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_TOTAL_EVER_ACTIVE_WORKSPACE_USERS_FIELD_ID,
position: 19,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'd61bbf0c-48ad-428a-90a1-87b54a87bacf',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_WORKSPACE_TENURE_FIELD_ID,
position: 20,
isVisible: true,
size: 140,
},
{
universalIdentifier: '26058a5b-edde-4caa-a034-32b8edb54ce6',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_IS_ACTIVE_L24H_FIELD_ID,
position: 21,
isVisible: true,
size: 120,
},
{
universalIdentifier: '36931d2b-f250-4325-a877-3285dd8a797e',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_IS_ACTIVE_L7D_FIELD_ID,
position: 22,
isVisible: true,
size: 120,
},
{
universalIdentifier: 'c120fc92-4796-4cf4-866f-c1d8c4555321',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_IS_ACTIVE_L30D_FIELD_ID,
position: 23,
isVisible: true,
size: 120,
},
{
universalIdentifier: 'a6674592-070b-41c1-baab-f360984328cc',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_IS_ENRICHED_FIELD_ID,
position: 24,
isVisible: true,
size: 110,
},
{
universalIdentifier: 'd3701295-ef41-46c4-8dd3-b31c27663516',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_CUSTOM_DOMAIN_FIELD_ID,
position: 25,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'dd22ffe7-95ed-4c67-be33-c2d1592daa32',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_WORKSPACE_BUSINESS_DOMAIN_FIELD_ID,
position: 26,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'c7a9bcf8-d41d-4ded-b680-868e470e3228',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_COMPANY_LINKEDIN_FIELD_ID,
position: 27,
isVisible: true,
size: 180,
},
{
universalIdentifier: '398eb88f-0d12-446d-a486-d1fb262f82bb',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_CREATOR_EMAIL_FIELD_ID,
position: 28,
isVisible: true,
size: 200,
},
{
universalIdentifier: '52d2a1b3-e2b2-4d6b-a5dd-ea8d63c7c369',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_TAGS_FIELD_ID,
position: 29,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'f74fed2c-94dc-4954-ac27-79acd7e04103',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_PAYMENT_FREQUENCY_FIELD_ID,
position: 30,
isVisible: true,
size: 150,
},
{
universalIdentifier: '666d0cea-e50b-4cc8-9ed5-5f5c0ec7fe7e',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_NEXT_RENEWAL_DATE_FIELD_ID,
position: 31,
isVisible: true,
size: 150,
},
{
universalIdentifier: '60508519-5ac4-4f96-a30f-5c75d3ce9a93',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_L30D_FIELD_ID,
position: 32,
isVisible: true,
size: 160,
},
{
universalIdentifier: '3b1ca2b6-31a1-4755-a9a4-07ee9a9b8a33',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_TOTAL_FIELD_ID,
position: 33,
isVisible: true,
size: 160,
},
{
universalIdentifier: '47ba21ab-a5c5-42b9-b1e2-7a988ece154d',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_ALEXA_RANK_FIELD_ID,
position: 34,
isVisible: true,
size: 110,
},
{
universalIdentifier: 'de5e2e42-2600-488c-81d0-f700e7dd0f67',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_COMPANY_FOUNDED_YEAR_FIELD_ID,
position: 35,
isVisible: true,
size: 160,
},
{
universalIdentifier: 'd6bec718-8b50-41e2-95d7-521151e2df82',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_LATEST_FUNDING_STAGE_FIELD_ID,
position: 36,
isVisible: true,
size: 160,
},
{
universalIdentifier: '9aaff045-64fa-4857-a8df-7394cf43a120',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_LAST_PAGE_VIEW_DATE_FIELD_ID,
position: 37,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'f2cb82b9-ebb4-4258-971b-061d709f28e5',
fieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_DATA_LAST_UPDATED_AT_FIELD_ID,
position: 38,
isVisible: true,
size: 150,
},
{
universalIdentifier: '7e631992-3ae6-478b-9874-67c3b211f05a',
fieldMetadataUniversalIdentifier: CLOUD_WORKSPACE_2_UPDATED_BY_FIELD_ID,
position: 39,
isVisible: true,
size: 150,
},
],
});
@@ -0,0 +1,31 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,9 @@ This document outlines the best practices you should follow when working on the
## State management
React and Jotai handle state management in the codebase.
React and Recoil handle state management in the codebase.
### Use Jotai atoms to store state
### Use `useRecoilState` to store state
It's good practice to create as many atoms as you need to store your state.
@@ -20,16 +20,13 @@ It's better to use extra atoms than trying to be too concise with props drilling
</Warning>
```tsx
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
export const myAtomState = createAtomState<string>({
export const myAtomState = atom({
key: 'myAtomState',
defaultValue: 'default value',
default: 'default value',
});
export const MyComponent = () => {
const [myAtom, setMyAtom] = useAtomState(myAtomState);
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
return (
<div>
@@ -46,7 +43,7 @@ export const MyComponent = () => {
Avoid using `useRef` to store state.
If you want to store state, you should use `useState` or Jotai atoms with `useAtomState`.
If you want to store state, you should use `useState` or `useRecoilState`.
See [how to manage re-renders](#managing-re-renders) if you feel like you need `useRef` to prevent some re-renders from happening.
@@ -86,8 +83,8 @@ You can apply the same for data fetching logic, with Apollo hooks.
// ❌ Bad, will cause re-renders even if data is not changing,
// because useEffect needs to be re-evaluated
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -99,7 +96,9 @@ export const PageComponent = () => {
};
export const App = () => (
<PageComponent />
<RecoilRoot>
<PageComponent />
</RecoilRoot>
);
```
@@ -107,14 +106,14 @@ export const App = () => (
// ✅ Good, will not cause re-renders if data is not changing,
// because useEffect is re-evaluated in another sibling component
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [data, setData] = useRecoilState(dataState);
return <div>{data}</div>;
};
export const PageData = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -126,16 +125,16 @@ export const PageData = () => {
};
export const App = () => (
<>
<RecoilRoot>
<PageData />
<PageComponent />
</>
</RecoilRoot>
);
```
### Use atom family states and selectors
### Use recoil family states and recoil family selectors
Atom family states and selectors are a great way to avoid re-renders.
Recoil family states and selectors are a great way to avoid re-renders.
They are useful when you need to store a list of items.
@@ -83,9 +83,9 @@ See [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) for more de
### States
Contains the state management logic. [Jotai](https://jotai.org) handles this.
Contains the state management logic. [RecoilJS](https://recoiljs.org) handles this.
- Selectors: Derived atoms (using `createAtomSelector`) compute values from other atoms and are automatically memoized.
- Selectors: See [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) for more details.
React's built-in state management still handles state within a component.
@@ -52,7 +52,7 @@ The project has a clean and simple stack, with minimal boilerplate code.
- [React](https://react.dev/)
- [Apollo](https://www.apollographql.com/docs/)
- [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
- [Jotai](https://jotai.org/)
- [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
- [TypeScript](https://www.typescriptlang.org/)
**Testing**
@@ -76,7 +76,7 @@ To avoid unnecessary [re-renders](/developers/contribute/capabilities/frontend-d
### State Management
[Jotai](https://jotai.org/) handles state management.
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) handles state management.
See [best practices](/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) for more information on state management.
@@ -159,7 +159,7 @@ export enum PageHotkeyScope {
}
```
Internally, the currently selected scope is stored in a Jotai atom that is shared across the application :
Internally, the currently selected scope is stored in a Recoil state that is shared across the application :
```tsx
export const currentHotkeyScopeState = createState<HotkeyScope>({
@@ -168,10 +168,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
});
```
But this atom should never be handled manually ! We'll see how to use it in the next section.
But this Recoil state should never be handled manually ! We'll see how to use it in the next section.
## How is it working internally?
We made a thin wrapper on top of [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) that makes it more performant and avoids unnecessary re-renders.
We also create a Jotai atom to handle the hotkey scope state and make it available everywhere in the application.
We also create a Recoil state to handle the hotkey scope state and make it available everywhere in the application.
@@ -31,6 +31,13 @@ Create a new app using the official scaffolder, then authenticate and start deve
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# If you don't use yarn@4
corepack enable
yarn install
# Authenticate using your API key (you'll be prompted)
yarn twenty auth:login
# Start dev mode: automatically syncs local changes to your workspace
yarn twenty app:dev
```
@@ -60,9 +67,6 @@ yarn twenty function:logs
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Execute the pre-install function
yarn twenty function:execute --preInstall
# Execute the post-install function
yarn twenty function:execute --postInstall
@@ -82,7 +86,7 @@ When you run `npx create-twenty-app@latest my-twenty-app`, the scaffolder:
- Copies a minimal base application into `my-twenty-app/`
- Adds a local `twenty-sdk` dependency and Yarn 4 configuration
- Creates config files and scripts wired to the `twenty` CLI
- Generates core files (application config, default function role, pre-install and post-install functions) plus example files based on the scaffolding mode
- Generates core files (application config, default function role, post-install function) plus example files based on the scaffolding mode
A freshly scaffolded app with the default `--exhaustive` mode looks like this:
@@ -109,7 +113,6 @@ my-twenty-app/
│ └── example-field.ts # Example standalone field definition
├── logic-functions/
│ ├── hello-world.ts # Example logic function
│ ├── pre-install.ts # Pre-install logic function
│ └── post-install.ts # Post-install logic function
├── front-components/
│ └── hello-world.tsx # Example front component
@@ -121,7 +124,7 @@ my-twenty-app/
└── example-skill.ts # Example AI agent skill definition
```
With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts`, and `logic-functions/post-install.ts`). With `--interactive`, you choose which example files to include.
With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, and `logic-functions/post-install.ts`). With `--interactive`, you choose which example files to include.
At a high level:
@@ -142,8 +145,6 @@ The SDK detects entities by parsing your TypeScript files for **`export default
|-----------------|-------------|
| `defineObject()` | Custom object definitions |
| `defineLogicFunction()` | Logic function definitions |
| `definePreInstallLogicFunction()` | Pre-install logic function (runs before installation) |
| `definePostInstallLogicFunction()` | Post-install logic function (runs after installation) |
| `defineFrontComponent()` | Front component definitions |
| `defineRole()` | Role definitions |
| `defineField()` | Field extensions for existing objects |
@@ -169,7 +170,7 @@ export default defineObject({
Later commands will add more files and folders:
- `yarn twenty app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty app:dev` will auto-generate a typed API client in `node_modules/twenty-sdk/generated` (typed Twenty client + workspace types).
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
## Authentication
@@ -218,8 +219,6 @@ The SDK provides helper functions for defining your app entities. As described i
| `defineApplication()` | Configure application metadata (required, one per app) |
| `defineObject()` | Define custom objects with fields |
| `defineLogicFunction()` | Define logic functions with handlers |
| `definePreInstallLogicFunction()` | Define a pre-install logic function (one per app) |
| `definePostInstallLogicFunction()` | Define a post-install logic function (one per app) |
| `defineFrontComponent()` | Define front components for custom UI |
| `defineRole()` | Configure role permissions and object access |
| `defineField()` | Extend existing objects with additional fields |
@@ -326,7 +325,6 @@ Every app has a single `application-config.ts` file that describes:
- **Who the app is**: identifiers, display name, and description.
- **How its functions run**: which role they use for permissions.
- **(Optional) variables**: keyvalue pairs exposed to your functions as environment variables.
- **(Optional) pre-install function**: a logic function that runs before the app is installed.
- **(Optional) post-install function**: a logic function that runs after the app is installed.
Use `defineApplication()` to define your application configuration:
@@ -335,6 +333,7 @@ Use `defineApplication()` to define your application configuration:
// src/application-config.ts
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: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -350,6 +349,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -357,7 +357,7 @@ Notes:
- `universalIdentifier` fields are deterministic IDs you own; generate them once and keep them stable across syncs.
- `applicationVariables` become environment variables for your functions (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`).
- `defaultRoleUniversalIdentifier` must match the role file (see below).
- Pre-install and post-install functions are automatically detected during the manifest build. See [Pre-install functions](#pre-install-functions) and [Post-install functions](#post-install-functions).
- `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### Roles and permissions
@@ -430,10 +430,10 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
// src/app/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
import Twenty, { type Person } from '~/generated';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const client = new Twenty(); // generated typed client
const name = 'name' in params.queryStringParameters
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
@@ -490,43 +490,6 @@ Notes:
- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions.
- You can mix multiple trigger types in a single function.
### Pre-install functions
A pre-install function is a logic function that runs automatically before your app is installed on a workspace. This is useful for validation tasks, prerequisite checks, or preparing workspace state before the main installation proceeds.
When you scaffold a new app with `create-twenty-app`, a pre-install function is generated for you at `src/logic-functions/pre-install.ts`:
```typescript
// src/logic-functions/pre-install.ts
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: '<generated-uuid>',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
```
You can also manually execute the pre-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --preInstall
```
Key points:
- Pre-install functions use `definePreInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs).
- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected.
- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks.
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `function:execute --preInstall`.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
@@ -535,14 +498,16 @@ When you scaffold a new app with `create-twenty-app`, a post-install function is
```typescript
// src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
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 = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default definePostInstallLogicFunction({
universalIdentifier: '<generated-uuid>',
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
@@ -550,6 +515,17 @@ export default definePostInstallLogicFunction({
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
@@ -557,10 +533,8 @@ yarn twenty function:execute --postInstall
```
Key points:
- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs).
- Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
- Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
- The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
@@ -668,10 +642,10 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
```typescript
// src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import Twenty from '~/generated';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const client = new Twenty();
const result = await client.mutation({
createTask: {
@@ -729,7 +703,7 @@ Key points:
Front components let you build custom React components that render within Twenty's UI. Use `defineFrontComponent()` to define components with built-in validation:
```typescript
// src/front-components/my-widget.tsx
// src/my-widget.front-component.tsx
import { defineFrontComponent } from 'twenty-sdk';
const MyWidget = () => {
@@ -751,13 +725,14 @@ export default defineFrontComponent({
Key points:
- Front components are React components that render in isolated contexts within Twenty.
- Use the `*.front-component.tsx` file suffix for automatic detection.
- The `component` field references your React component.
- Components are built and synced automatically during `yarn twenty app:dev`.
You can create new front components in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new front component.
- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern.
- **Manual**: Create a new `*.front-component.tsx` file and use `defineFrontComponent()`.
### Skills
@@ -793,24 +768,18 @@ You can create new skills in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
- **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
### Generated typed clients
### Generated typed client
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
The typed client is auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema. Use it in your functions:
```typescript
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
import Twenty from '~/generated';
const client = new CoreApiClient();
const client = new Twenty();
const { me } = await client.query({ me: { id: true, displayName: true } });
const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
The client is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
#### Runtime credentials in logic functions
@@ -826,17 +795,17 @@ Notes:
#### Uploading files
The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
The generated `Twenty` client includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
```typescript
import { MetadataApiClient } from 'twenty-sdk/generated';
import Twenty from '~/generated';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
const client = new Twenty();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
const uploadedFile = await client.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type (defaults to 'application/octet-stream')
@@ -866,7 +835,7 @@ uploadFile(
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
Key points:
- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint.
- The method sends the file to the **metadata endpoint** (not the main GraphQL endpoint), where the upload mutation is resolved.
- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else.
- The returned `url` is a signed URL you can use to access the uploaded file.
@@ -11,7 +11,7 @@ title: كائنات مخصصة
## مخطط على مستوى عالي
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="مخطط على مستوى عالي" />
<img src="/images/docs/server/custom-object-schema.png" alt="مخطط على مستوى عالي" />
</div>
<br />
@@ -27,7 +27,7 @@ title: كائنات مخصصة
لإضافة كائن مخصص، سيقوم عضو مساحة العمل بالاستعلام عن واجهة برمجة التطبيقات /metadata. يقوم هذا بتحديث البيانات الوصفية وفقًا لذلك ويحسب مخطط GraphQL استنادًا إلى البيانات الوصفية، ويخزنها في ذاكرة GQL للاستخدام لاحقًا.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/add-custom-objects.jpeg" alt="استعلام واجهة برمجة التطبيقات /metadata لإضافة الكائنات المخصصة" />
<img src="/images/docs/server/add-custom-objects.jpeg" alt="استعلام واجهة برمجة التطبيقات /metadata لإضافة الكائنات المخصصة" />
</div>
<br />
@@ -35,5 +35,5 @@ title: كائنات مخصصة
لجلب البيانات، تتضمن العملية إجراء استعلامات من خلال نقطة النهاية /graphql وتمريرها من خلال محلل الاستعلام.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="استعلام نقطة النهاية /graphql لجلب البيانات" />
<img src="/images/docs/server/custom-object-schema.png" alt="استعلام نقطة النهاية /graphql لجلب البيانات" />
</div>
@@ -60,11 +60,9 @@ npx nx run twenty-server:command workspace:sync-metadata -f
```
<Warning>
سيؤدي هذا إلى إسقاط قاعدة البيانات وإعادة تشغيل الهجرات والبذور.
سيؤدي هذا إلى إسقاط قاعدة البيانات وإعادة تشغيل الهجرات والبذور.
تأكد من عمل نسخة احتياطية لأي بيانات تريد الاحتفاظ بها قبل تشغيل هذا الأمر.
تأكد من عمل نسخة احتياطية لأي بيانات تريد الاحتفاظ بها قبل تشغيل هذا الأمر.
</Warning>
## "التقنية المستخدمة"

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