Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0646b69894 | ||
|
|
e8dabf7755 | ||
|
|
ae291c99ba | ||
|
|
7809f83e72 | ||
|
|
27847f6ac6 | ||
|
|
6351c6c1c6 | ||
|
|
20a2c3836e | ||
|
|
1eb284c87f | ||
|
|
c4140f85df | ||
|
|
9c4b0f526c | ||
|
|
37bcb35391 | ||
|
|
78a0197643 | ||
|
|
ff3326a53b | ||
|
|
5e92fb4fc6 | ||
|
|
1a8be234de | ||
|
|
d021f7e369 | ||
|
|
1b67ba6a75 | ||
|
|
5afc46ebd3 | ||
|
|
a06abb1d60 | ||
|
|
2a5b2746c9 | ||
|
|
0223975bbd | ||
|
|
8d47d8ae38 | ||
|
|
68d2297338 | ||
|
|
1db2a40961 | ||
|
|
159bb9d70a | ||
|
|
b341704a0e | ||
|
|
d5d0f5d994 | ||
|
|
012d819557 | ||
|
|
9fe2a07c55 | ||
|
|
63f17eec2c | ||
|
|
e806d36099 | ||
|
|
9f5a8735c9 | ||
|
|
c0e6aa1c0b | ||
|
|
9342b16aad | ||
|
|
cfad24da48 | ||
|
|
86fbf69e95 | ||
|
|
9667b3f369 | ||
|
|
76c7639eb3 | ||
|
|
4ed09a3feb | ||
|
|
def5ea5764 | ||
|
|
81698ff32c | ||
|
|
c0c51f2ef5 | ||
|
|
3bbaff801a | ||
|
|
2d2fc06265 | ||
|
|
8a7a19f312 | ||
|
|
2f0103faa5 |
@@ -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 Recoil (Auto-attached to state files)
|
||||
- **react-state-management.mdc** - State management approaches with Jotai (Auto-attached to state files)
|
||||
|
||||
### Testing & Quality
|
||||
- **testing-guidelines.mdc** - Testing strategies and best practices (Auto-attached to test files)
|
||||
|
||||
@@ -7,7 +7,7 @@ alwaysApply: true
|
||||
# Twenty Architecture
|
||||
|
||||
## Tech Stack
|
||||
- **Frontend**: React 18, TypeScript, Recoil, Styled Components, Vite
|
||||
- **Frontend**: React 18, TypeScript, Jotai, Styled Components, Vite
|
||||
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL
|
||||
- **Monorepo**: Nx workspace with yarn
|
||||
|
||||
|
||||
@@ -4,16 +4,20 @@ alwaysApply: false
|
||||
---
|
||||
# React State Management
|
||||
|
||||
## Recoil Patterns
|
||||
## Jotai Patterns
|
||||
```typescript
|
||||
// ✅ Atoms for primitive state
|
||||
export const currentUserState = atom<User | null>({
|
||||
// ✅ 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>({
|
||||
key: 'currentUserState',
|
||||
default: null,
|
||||
defaultValue: null,
|
||||
});
|
||||
|
||||
// ✅ Selectors for derived state
|
||||
export const userDisplayNameSelector = selector({
|
||||
// ✅ Derived atoms for computed state (use createAtomSelector)
|
||||
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
|
||||
|
||||
export const userDisplayNameSelector = createAtomSelector({
|
||||
key: 'userDisplayNameSelector',
|
||||
get: ({ get }) => {
|
||||
const user = get(currentUserState);
|
||||
@@ -21,13 +25,30 @@ export const userDisplayNameSelector = selector({
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ Atom families for dynamic atoms
|
||||
export const userByIdState = atomFamily<User | null, string>({
|
||||
// ✅ Atom factory pattern for dynamic atoms (use createAtomFamilyState)
|
||||
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
|
||||
|
||||
export const userByIdState = createAtomFamilyState<User | null, string>({
|
||||
key: 'userByIdState',
|
||||
default: null,
|
||||
defaultValue: 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
|
||||
@@ -74,7 +95,7 @@ const increment = useCallback(() => {
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
- Use atom families for dynamic data collections
|
||||
- Implement proper selector caching
|
||||
- Avoid heavy computations in selectors
|
||||
- Use atom factory pattern (createAtomFamilyState) for dynamic data collections
|
||||
- Derived atoms (createAtomSelector) are automatically memoized by Jotai
|
||||
- Avoid heavy computations in derived atoms
|
||||
- Batch state updates when possible
|
||||
|
||||
@@ -19,4 +19,10 @@ runs:
|
||||
uses: nrwl/nx-set-shas@v4
|
||||
- name: Run affected command
|
||||
shell: bash
|
||||
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}' ${{ inputs.args }}
|
||||
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
|
||||
@@ -19,8 +19,11 @@ 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-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${CACHE_KEY}-${REF_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Restore cache
|
||||
uses: actions/cache/restore@v4
|
||||
id: restore-cache
|
||||
|
||||
@@ -16,8 +16,6 @@ env:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
@@ -584,182 +582,16 @@ jobs:
|
||||
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
|
||||
fi
|
||||
|
||||
- name: Comment API Changes on PR
|
||||
- name: Upload breaking changes report
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
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: breaking-changes-report
|
||||
path: |
|
||||
*-diff.md
|
||||
*-diff.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Cleanup servers
|
||||
if: always()
|
||||
@@ -771,16 +603,4 @@ 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
|
||||
|
||||
|
||||
@@ -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,15 +296,18 @@ 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'
|
||||
# - 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
|
||||
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
|
||||
|
||||
- name: Build server
|
||||
@@ -336,12 +339,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()
|
||||
@@ -352,7 +355,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,
|
||||
]
|
||||
|
||||
@@ -9,10 +9,7 @@ on:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
checks: write
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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
|
||||
@@ -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; print(urllib.parse.quote('$BRANCH', safe=''))")
|
||||
ENCODED_BRANCH=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$BRANCH")
|
||||
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,18 +157,11 @@ jobs:
|
||||
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
|
||||
}
|
||||
}
|
||||
- name: Post response to source issue
|
||||
- name: Dispatch response to ci-privileged
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
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})`
|
||||
});
|
||||
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)) }}}'
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# Weekly translation QA report using Crowdin's native QA checks
|
||||
|
||||
name: 'Weekly Translation QA Report'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Every Monday at 9am UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qa_report:
|
||||
name: Generate QA Report
|
||||
runs-on: 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 }}
|
||||
@@ -0,0 +1,71 @@
|
||||
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) }}}'
|
||||
@@ -2,13 +2,8 @@ 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:
|
||||
@@ -24,7 +19,19 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
trigger-preview:
|
||||
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || (github.event.action == 'labeled' && github.event.label.name == 'preview-app')
|
||||
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'
|
||||
)
|
||||
)
|
||||
timeout-minutes: 5
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
@@ -35,3 +42,11 @@ 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) }}}'
|
||||
|
||||
@@ -2,7 +2,6 @@ name: 'Preview Environment Keep Alive'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
@@ -55,14 +54,15 @@ jobs:
|
||||
port: 3000
|
||||
|
||||
- name: Start services with correct SERVER_URL
|
||||
env:
|
||||
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
||||
run: |
|
||||
cd packages/twenty-docker/
|
||||
|
||||
# Update the SERVER_URL with the tunnel URL
|
||||
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
|
||||
echo "Setting SERVER_URL to $TUNNEL_URL"
|
||||
sed -i '/SERVER_URL=/d' .env
|
||||
echo "" >> .env
|
||||
echo "SERVER_URL=${{ steps.expose-tunnel.outputs.tunnel-url }}" >> .env
|
||||
echo "SERVER_URL=$TUNNEL_URL" >> .env
|
||||
|
||||
# Start the services
|
||||
echo "Docker compose up..."
|
||||
@@ -99,54 +99,26 @@ jobs:
|
||||
fi
|
||||
working-directory: ./
|
||||
|
||||
- name: Output tunnel URL to logs
|
||||
- name: Output tunnel URL
|
||||
env:
|
||||
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
||||
run: |
|
||||
echo "✅ Preview Environment Ready!"
|
||||
echo "🔗 Preview URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}"
|
||||
echo "🔗 Preview URL: $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: Post comment on PR
|
||||
uses: actions/github-script@v6
|
||||
- name: Upload tunnel URL artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
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: tunnel-url
|
||||
path: tunnel-url.txt
|
||||
retention-days: 1
|
||||
|
||||
- name: Keep tunnel alive for 5 hours
|
||||
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
|
||||
|
||||
@@ -90,7 +90,7 @@ npx nx run twenty-front:graphql:generate --configuration=metadata
|
||||
## Architecture Overview
|
||||
|
||||
### Tech Stack
|
||||
- **Frontend**: React 18, TypeScript, Recoil (state management), Emotion (styling), Vite
|
||||
- **Frontend**: React 18, TypeScript, Jotai (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
|
||||
- **Recoil** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
|
||||
- **Jotai** 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)`
|
||||
|
||||
@@ -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 [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
|
||||
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
# 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 0–32 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 0–32 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
|
||||
@@ -83,6 +83,10 @@ export default [
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:zapier'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
# 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.
@@ -126,7 +126,7 @@
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"maxWorkers": 3
|
||||
"maxWorkers": 1
|
||||
},
|
||||
"coverage": {
|
||||
"coverageReporters": ["lcov", "text"]
|
||||
|
||||
+8
-11
@@ -48,8 +48,7 @@
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-router-dom": "^6.4.4",
|
||||
"react-tooltip": "^5.13.1",
|
||||
"recoil": "^0.7.7",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.2.0",
|
||||
"semver": "^7.5.4",
|
||||
"slash": "^5.1.0",
|
||||
@@ -84,11 +83,11 @@
|
||||
"@sentry/types": "^8",
|
||||
"@storybook-community/storybook-addon-cookie": "^5.0.0",
|
||||
"@storybook/addon-coverage": "^3.0.0",
|
||||
"@storybook/addon-docs": "^10.1.11",
|
||||
"@storybook/addon-links": "^10.1.11",
|
||||
"@storybook/addon-vitest": "^10.1.11",
|
||||
"@storybook/addon-docs": "^10.2.13",
|
||||
"@storybook/addon-links": "^10.2.13",
|
||||
"@storybook/addon-vitest": "^10.2.13",
|
||||
"@storybook/icons": "^2.0.1",
|
||||
"@storybook/react-vite": "^10.1.11",
|
||||
"@storybook/react-vite": "^10.2.13",
|
||||
"@storybook/test-runner": "^0.24.2",
|
||||
"@stylistic/eslint-plugin": "^1.5.0",
|
||||
"@swc-node/register": "1.11.1",
|
||||
@@ -160,7 +159,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"eslint-plugin-storybook": "^10.1.11",
|
||||
"eslint-plugin-storybook": "^10.2.13",
|
||||
"eslint-plugin-unicorn": "^56.0.1",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"http-server": "^14.1.1",
|
||||
@@ -176,9 +175,9 @@
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.5",
|
||||
"source-map-support": "^0.5.20",
|
||||
"storybook": "^10.1.11",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-addon-mock-date": "2.0.0",
|
||||
"storybook-addon-pseudo-states": "^10.1.11",
|
||||
"storybook-addon-pseudo-states": "^10.2.13",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.2.3",
|
||||
@@ -202,8 +201,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -31,10 +31,6 @@ 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
|
||||
|
||||
@@ -45,7 +41,7 @@ yarn twenty auth:login
|
||||
yarn twenty entity:add
|
||||
|
||||
# Start dev mode: watches, builds, and syncs local changes to your workspace
|
||||
# (also auto-generates a typed API client in node_modules/twenty-sdk/generated)
|
||||
# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated)
|
||||
yarn twenty app:dev
|
||||
|
||||
# Watch your application's function logs
|
||||
@@ -54,6 +50,9 @@ 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
|
||||
|
||||
@@ -96,6 +95,7 @@ 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.
|
||||
- Types are auto‑generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`.
|
||||
- Two typed API clients are auto‑generated 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`).
|
||||
|
||||
|
||||
## Publish your application
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const jestConfig = {
|
||||
displayName: 'twenty-cli',
|
||||
displayName: 'create-twenty-app',
|
||||
preset: '../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transformIgnorePatterns: ['../../node_modules/'],
|
||||
@@ -15,6 +15,7 @@ const jestConfig = {
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^package.json$': '<rootDir>/package.json',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.3",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
- 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(` 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'));
|
||||
console.log(
|
||||
chalk.gray(' yarn twenty auth:login # Authenticate with Twenty'),
|
||||
);
|
||||
console.log(chalk.gray(' yarn twenty 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,7 +41,6 @@ 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)}`,
|
||||
@@ -51,7 +50,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temp directory after each test
|
||||
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
|
||||
await fs.remove(testAppDirectory);
|
||||
}
|
||||
@@ -66,15 +64,12 @@ 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);
|
||||
});
|
||||
@@ -94,7 +89,9 @@ describe('copyBaseApplicationProject', () => {
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
expect(packageJson.name).toBe('my-test-app');
|
||||
expect(packageJson.version).toBe('0.1.0');
|
||||
expect(packageJson.dependencies['twenty-sdk']).toBe('latest');
|
||||
expect(packageJson.devDependencies['twenty-sdk']).toBe(
|
||||
createTwentyAppPackageJson.version,
|
||||
);
|
||||
expect(packageJson.scripts['twenty']).toBe('twenty');
|
||||
});
|
||||
|
||||
@@ -143,27 +140,22 @@ 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',
|
||||
);
|
||||
@@ -186,29 +178,24 @@ 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/,
|
||||
);
|
||||
@@ -223,7 +210,6 @@ 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'),
|
||||
@@ -247,7 +233,6 @@ 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({
|
||||
@@ -258,7 +243,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
@@ -269,7 +253,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Read both app configs
|
||||
const firstAppConfig = await fs.readFile(
|
||||
join(firstAppDir, 'src', APPLICATION_FILE_NAME),
|
||||
'utf8',
|
||||
@@ -279,7 +262,6 @@ 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];
|
||||
@@ -291,7 +273,6 @@ 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({
|
||||
@@ -302,7 +283,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
@@ -323,7 +303,6 @@ 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];
|
||||
@@ -375,6 +354,18 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -390,7 +381,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
|
||||
const srcPath = join(testAppDirectory, 'src');
|
||||
|
||||
// Core files should exist
|
||||
expect(await fs.pathExists(join(srcPath, APPLICATION_FILE_NAME))).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -398,7 +388,18 @@ describe('copyBaseApplicationProject', () => {
|
||||
await fs.pathExists(join(srcPath, 'roles', DEFAULT_ROLE_FILE_NAME)),
|
||||
).toBe(true);
|
||||
|
||||
// Example files should not exist
|
||||
// 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);
|
||||
|
||||
expect(
|
||||
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
|
||||
).toBe(false);
|
||||
@@ -676,4 +677,124 @@ 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,6 +4,7 @@ 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';
|
||||
|
||||
@@ -97,6 +98,12 @@ export const copyBaseApplicationProject = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await createDefaultPreInstallFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'pre-install.ts',
|
||||
});
|
||||
|
||||
await createDefaultPostInstallFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
@@ -140,8 +147,7 @@ generated
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
.twenty/*
|
||||
!.twenty/output/
|
||||
.twenty
|
||||
|
||||
# production
|
||||
/build
|
||||
@@ -268,6 +274,36 @@ 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,
|
||||
@@ -279,16 +315,14 @@ const createDefaultPostInstallFunction = async ({
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
||||
const content = `import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
@@ -477,14 +511,12 @@ 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,
|
||||
});
|
||||
`;
|
||||
|
||||
@@ -514,10 +546,9 @@ const createPackageJson = async ({
|
||||
lint: 'eslint',
|
||||
'lint:fix': 'eslint --fix',
|
||||
},
|
||||
dependencies: {
|
||||
'twenty-sdk': 'latest',
|
||||
},
|
||||
dependencies: {},
|
||||
devDependencies: {
|
||||
'twenty-sdk': createTwentyAppPackageJson.version,
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
'@types/react': '^18.2.0',
|
||||
|
||||
@@ -6,7 +6,13 @@ const execPromise = promisify(exec);
|
||||
|
||||
export const install = async (root: string) => {
|
||||
try {
|
||||
await execPromise('yarn', { cwd: root });
|
||||
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 });
|
||||
} catch (error: any) {
|
||||
console.error(chalk.red('yarn install failed:'), error.stdout);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"noEmit": true,
|
||||
"types": ["jest", "node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"package.json": ["./package.json"]
|
||||
},
|
||||
"jsx": "react"
|
||||
},
|
||||
|
||||
@@ -1,2 +1,37 @@
|
||||
.yarn/install-state.gz
|
||||
.env
|
||||
# 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
|
||||
|
||||
@@ -2,25 +2,6 @@
|
||||
|
||||
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,26 +9,13 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"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",
|
||||
"twenty": "twenty",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "0.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
"@types/node": "^24.7.2",
|
||||
"twenty-sdk": "0.6.2"
|
||||
},
|
||||
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
|
||||
"universalIdentifier": "a7070f46-3158-4b40-828f-8e6b1febc233"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
});
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
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}.*`,
|
||||
},
|
||||
});
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export type TelemetryEvent = {
|
||||
action: string;
|
||||
workspaceId?: string;
|
||||
userWorkspaceId?: string;
|
||||
userId: string;
|
||||
userEmail?: string;
|
||||
userFirstName?: string;
|
||||
userLastName?: string;
|
||||
locale?: string;
|
||||
serverUrl: string;
|
||||
serverId: string;
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
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,13 +5,14 @@
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
@@ -26,10 +27,5 @@
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"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
@@ -1,38 +0,0 @@
|
||||
# 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
|
||||
@@ -1 +0,0 @@
|
||||
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
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
-68802
File diff suppressed because it is too large
Load Diff
-7
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -1,9 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,51 +0,0 @@
|
||||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn 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!
|
||||
@@ -1,29 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
// Base JS recommended rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript recommended rules
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Common TypeScript-friendly tweaks
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-unused-vars': 'off', // handled by TS rule
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
// 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';
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
// 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';
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
// 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';
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
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 * * * *',
|
||||
},
|
||||
});
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
-268
@@ -1,268 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
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,
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
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,
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
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,
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,589 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
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,
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
// 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();
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import Twenty from 'twenty-sdk/generated';
|
||||
|
||||
export const twentyClient = new Twenty();
|
||||
@@ -1,159 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,368 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"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
+19
-18
@@ -7,9 +7,9 @@ This document outlines the best practices you should follow when working on the
|
||||
|
||||
## State management
|
||||
|
||||
React and Recoil handle state management in the codebase.
|
||||
React and Jotai handle state management in the codebase.
|
||||
|
||||
### Use `useRecoilState` to store state
|
||||
### Use Jotai atoms to store state
|
||||
|
||||
It's good practice to create as many atoms as you need to store your state.
|
||||
|
||||
@@ -20,13 +20,16 @@ It's better to use extra atoms than trying to be too concise with props drilling
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -43,7 +46,7 @@ export const MyComponent = () => {
|
||||
|
||||
Avoid using `useRef` to store state.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
If you want to store state, you should use `useState` or Jotai atoms with `useAtomState`.
|
||||
|
||||
See [how to manage re-renders](#managing-re-renders) if you feel like you need `useRef` to prevent some re-renders from happening.
|
||||
|
||||
@@ -83,8 +86,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] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -96,9 +99,7 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
@@ -106,14 +107,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] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -125,16 +126,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Use recoil family states and recoil family selectors
|
||||
### Use atom family states and selectors
|
||||
|
||||
Recoil family states and selectors are a great way to avoid re-renders.
|
||||
Atom family states and selectors are a great way to avoid re-renders.
|
||||
|
||||
They are useful when you need to store a list of items.
|
||||
|
||||
|
||||
+2
-2
@@ -83,9 +83,9 @@ See [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) for more de
|
||||
|
||||
### States
|
||||
|
||||
Contains the state management logic. [RecoilJS](https://recoiljs.org) handles this.
|
||||
Contains the state management logic. [Jotai](https://jotai.org) handles this.
|
||||
|
||||
- Selectors: See [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) for more details.
|
||||
- Selectors: Derived atoms (using `createAtomSelector`) compute values from other atoms and are automatically memoized.
|
||||
|
||||
React's built-in state management still handles state within a component.
|
||||
|
||||
|
||||
+2
-2
@@ -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)
|
||||
- [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
- [Jotai](https://jotai.org/)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Testing**
|
||||
@@ -76,7 +76,7 @@ To avoid unnecessary [re-renders](/developers/contribute/capabilities/frontend-d
|
||||
|
||||
### State Management
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) handles state management.
|
||||
[Jotai](https://jotai.org/) handles state management.
|
||||
|
||||
See [best practices](/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) for more information on state management.
|
||||
|
||||
|
||||
+3
-3
@@ -159,7 +159,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
Internally, the currently selected scope is stored in a Recoil state that is shared across the application :
|
||||
Internally, the currently selected scope is stored in a Jotai atom that is shared across the application :
|
||||
|
||||
```tsx
|
||||
export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
@@ -168,10 +168,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
});
|
||||
```
|
||||
|
||||
But this Recoil state should never be handled manually ! We'll see how to use it in the next section.
|
||||
But this atom 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 Recoil state to handle the hotkey scope state and make it available everywhere in the application.
|
||||
We also create a Jotai atom to handle the hotkey scope state and make it available everywhere in the application.
|
||||
|
||||
@@ -31,13 +31,6 @@ 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
|
||||
```
|
||||
@@ -67,6 +60,9 @@ 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
|
||||
|
||||
@@ -86,7 +82,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, post-install function) plus example files based on the scaffolding mode
|
||||
- Generates core files (application config, default function role, pre-install and post-install functions) plus example files based on the scaffolding mode
|
||||
|
||||
A freshly scaffolded app with the default `--exhaustive` mode looks like this:
|
||||
|
||||
@@ -113,6 +109,7 @@ 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
|
||||
@@ -124,7 +121,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`, 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`, `logic-functions/pre-install.ts`, and `logic-functions/post-install.ts`). With `--interactive`, you choose which example files to include.
|
||||
|
||||
At a high level:
|
||||
|
||||
@@ -145,6 +142,8 @@ 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 |
|
||||
@@ -170,7 +169,7 @@ export default defineObject({
|
||||
|
||||
Later commands will add more files and folders:
|
||||
|
||||
- `yarn twenty app:dev` will auto-generate a typed API client in `node_modules/twenty-sdk/generated` (typed Twenty client + workspace types).
|
||||
- `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 entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
|
||||
|
||||
## Authentication
|
||||
@@ -219,6 +218,8 @@ 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 |
|
||||
@@ -325,6 +326,7 @@ 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**: key–value 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:
|
||||
@@ -333,7 +335,6 @@ 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',
|
||||
@@ -349,7 +350,6 @@ 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).
|
||||
- `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
- 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).
|
||||
|
||||
#### 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 Twenty, { type Person } from '~/generated';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new Twenty(); // generated typed client
|
||||
const client = new CoreApiClient();
|
||||
const name = 'name' in params.queryStringParameters
|
||||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
@@ -490,6 +490,43 @@ 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.
|
||||
@@ -498,16 +535,14 @@ When you scaffold a new app with `create-twenty-app`, a post-install function is
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '<generated-uuid>',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
@@ -515,17 +550,6 @@ export default defineLogicFunction({
|
||||
});
|
||||
```
|
||||
|
||||
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"
|
||||
@@ -533,8 +557,10 @@ yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
- 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.
|
||||
- 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()`.
|
||||
- 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`.
|
||||
|
||||
@@ -642,10 +668,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 Twenty from '~/generated';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result = await client.mutation({
|
||||
createTask: {
|
||||
@@ -703,7 +729,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/my-widget.front-component.tsx
|
||||
// src/front-components/my-widget.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
const MyWidget = () => {
|
||||
@@ -725,14 +751,13 @@ 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 `*.front-component.tsx` file and use `defineFrontComponent()`.
|
||||
- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern.
|
||||
|
||||
### Skills
|
||||
|
||||
@@ -768,18 +793,24 @@ 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 client
|
||||
### Generated typed clients
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
|
||||
```
|
||||
|
||||
The client is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
|
||||
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
|
||||
|
||||
#### Runtime credentials in logic functions
|
||||
|
||||
@@ -795,17 +826,17 @@ Notes:
|
||||
|
||||
#### Uploading files
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const client = new Twenty();
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
|
||||
const uploadedFile = await client.uploadFile(
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
@@ -835,7 +866,7 @@ uploadFile(
|
||||
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
|
||||
|
||||
Key points:
|
||||
- The method sends the file to the **metadata endpoint** (not the main GraphQL endpoint), where the upload mutation is resolved.
|
||||
- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint.
|
||||
- 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.
|
||||
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+4
-2
@@ -60,9 +60,11 @@ 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
Reference in New Issue
Block a user