Compare commits

..
Author SHA1 Message Date
Félix Malfait b82ae2bb8b Clean up email body handling: explicit HTML contract, no format guessing
The email tool's composeEmail service now always receives HTML body.
Workflow actions render their TipTap JSONContent to HTML at the boundary
via a new renderRichTextToHtml utility, instead of relying on magic
JSON.parse detection inside the shared service.

- Delete parseEmailBody (format-guessing utility)
- Simplify email-composer.service to sanitize + plain-text only
- Move JSONContent rendering to workflow action boundary
- Update tool schema to describe body as HTML format

Made-with: Cursor
2026-04-03 19:16:01 +02:00
Félix Malfait 6e206bc29b Remove IS_DRAFT_EMAIL_ENABLED feature flag, always request gmail.compose scope
The gmail.compose OAuth scope was conditionally requested based on the
IS_DRAFT_EMAIL_ENABLED feature flag, which meant email draft creation
silently failed for workspaces where the flag was off (the default).
This removes the feature flag entirely and always requests the
gmail.compose scope alongside gmail.send.

Made-with: Cursor
2026-04-03 19:04:19 +02:00
3123 changed files with 99804 additions and 174554 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"env": {}
},
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["@playwright/mcp@latest", "--no-sandbox", "--headless"],
"env": {}
},
"context7": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {}
}
}
}
-223
View File
@@ -1,223 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Twenty is an open-source CRM built with modern technologies in a monorepo structure. The codebase is organized as an Nx workspace with multiple packages.
## Key Commands
### Development
```bash
# Start development environment (frontend + backend + worker)
yarn start
# Individual package development
npx nx start twenty-front # Start frontend dev server
npx nx start twenty-server # Start backend server
npx nx run twenty-server:worker # Start background worker
```
### Testing
```bash
# Preferred: run a single test file (fast)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Run all tests for a package
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# To run an indivual test or a pattern of tests, use the following command:
cd packages/{workspace} && npx jest "pattern or filename"
# Storybook
npx nx storybook:build twenty-front
npx nx storybook:test twenty-front
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
```
### Code Quality
```bash
# Linting (diff with main - fastest, always prefer this)
npx nx lint:diff-with-main twenty-front
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
# Linting (full project - slower, use only when needed)
npx nx lint twenty-front
npx nx lint twenty-server
# Type checking
npx nx typecheck twenty-front
npx nx typecheck twenty-server
# Format code
npx nx fmt twenty-front
npx nx fmt twenty-server
```
### Build
```bash
# Build packages (twenty-shared must be built first)
npx nx build twenty-shared
npx nx build twenty-front
npx nx build twenty-server
```
### Database Operations
```bash
# Database management
npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run instance commands (fast only)
# Generate an instance command (fast or slow)
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
```
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
### Package Structure
```
packages/
├── twenty-front/ # React frontend application
├── twenty-server/ # NestJS backend API
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website/ # Next.js documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
```
### Key Development Principles
- **Functional components only** (no class components)
- **Named exports only** (no default exports)
- **Types over interfaces** (except when extending third-party interfaces)
- **String literals over enums** (except for GraphQL enums)
- **No 'any' type allowed** — strict TypeScript enforced
- **Event handlers preferred over useEffect** for state updates
- **Props down, events up** — unidirectional data flow
- **Composition over inheritance**
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
### Naming Conventions
- **Variables/functions**: camelCase
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
- **TypeScript generics**: descriptive names (`TData` not `T`)
### File Structure
- Components under 300 lines, services under 500 lines
- Components in their own directories with tests and stories
- Use `index.ts` barrel exports for clean imports
- Import order: external libraries first, then internal (`@/`), then relative
### Comments
- Use short-form comments (`//`), not JSDoc blocks
- Explain WHY (business logic), not WHAT
- Do not comment obvious code
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- 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)`
### Backend Architecture
- **NestJS modules** for feature organization
- **TypeORM** for database ORM with PostgreSQL
- **GraphQL** API with code-first approach
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database & Upgrade Commands
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **ClickHouse** for analytics (when enabled)
- When changing entity files, generate an **instance command** (`database:migrate:generate --name <name> --type <fast|slow>`)
- **Fast** instance commands handle schema changes; **slow** ones add a `runDataMigration` step for data backfills
- **Workspace commands** iterate over all active/suspended workspaces for per-workspace upgrades
- Commands use `@RegisteredInstanceCommand` and `@RegisteredWorkspaceCommand` decorators for automatic discovery
- Include both `up` and `down` logic in instance commands
- Never delete or rewrite committed instance command `up`/`down` logic
- See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation
### Utility Helpers
Use existing helpers from `twenty-shared` instead of manual type guards:
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
## Development Workflow
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
### Before Making Changes
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
2. Test changes with relevant test suites (prefer single-file test runs)
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
4. Check that GraphQL schema changes are backward compatible
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern)
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
### Testing Strategy
- **Test behavior, not implementation** — focus on user perspective
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
- Query by user-visible elements (text, roles, labels) over test IDs
- Use `@testing-library/user-event` for realistic interactions
- Descriptive test names: "should [behavior] when [condition]"
- Clear mocks between tests with `jest.clearAllMocks()`
## Dev Environment Setup
All dev environments (Claude Code web, Cursor, local) use one script:
```bash
bash packages/twenty-utils/setup-dev-env.sh
```
This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, and copies `.env` files. Idempotent — safe to run multiple times.
- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`)
- `--down` — stop services
- `--reset` — wipe data and restart fresh
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
**Note:** CI workflows (GitHub Actions) manage services via Actions service containers and run setup steps individually — they don't use this script.
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
- `tsconfig.base.json` - Base TypeScript configuration
- `package.json` - Root package with workspace definitions
- `.cursor/rules/` - Detailed development guidelines and best practices
+6 -3
View File
@@ -12,7 +12,7 @@ This directory contains Twenty's development guidelines and best practices in th
### Core Guidelines
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
- **server-migrations.mdc** - Upgrade command guidelines (instance commands and workspace commands) for `twenty-server` (Auto-attached to server entities and upgrade command files)
- **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files)
- **creating-syncable-entity.mdc** - Comprehensive guide for creating new syncable entities (with universalIdentifier and applicationId) in the workspace migration system (Agent-requested for metadata-modules and workspace-migration files)
### Code Quality
@@ -81,8 +81,11 @@ npx nx run twenty-server:typecheck # Type checking
npx nx run twenty-server:test # Run unit tests
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
# Upgrade commands (instance + workspace)
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
# Migrations
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/[name] -d src/database/typeorm/core/core.datasource.ts
# Workspace
npx nx run twenty-server:command workspace:sync-metadata -f # Sync metadata
```
## Usage Guidelines
+1 -2
View File
@@ -55,8 +55,7 @@ If feature descriptions are not provided or need enhancement, research the codeb
- Services: Look for `*.service.ts` files
**For Database/ORM Changes:**
- Instance commands (fast/slow): `packages/twenty-server/src/database/commands/upgrade-version-command/`
- Legacy TypeORM migrations: `packages/twenty-server/src/database/typeorm/`
- Migrations: `packages/twenty-server/src/database/typeorm/`
- Entities: `packages/twenty-server/src/entities/`
### Research Commands
+1 -1
View File
@@ -22,7 +22,7 @@ This main guide provides a high-level overview and navigation hub.
A syncable entity is a metadata entity that:
- Has a **`universalIdentifier`**: A unique identifier used for syncing entities across workspaces/applications
- Has an **`applicationId`**: Links the entity to an application (Standard or Custom applications)
- Has an **`applicationId`**: Links the entity to an application (Twenty Standard or Custom applications)
- Participates in the **workspace migration system**: Can be created, updated, and deleted through the migration pipeline
- Is **cached as a flat entity**: Denormalized representation for efficient validation and change detection
+14 -31
View File
@@ -1,46 +1,29 @@
---
description: Guidelines for generating and managing upgrade commands (instance commands and workspace commands) in twenty-server
description: Guidelines for generating and managing TypeORM migrations in twenty-server
globs: [
"packages/twenty-server/src/**/*.entity.ts",
"packages/twenty-server/src/database/commands/upgrade-version-command/**/*.ts"
"packages/twenty-server/src/database/typeorm/**/*.ts"
]
alwaysApply: false
---
## Upgrade Commands (twenty-server)
## Server Migrations (twenty-server)
The upgrade system uses two types of commands instead of raw TypeORM migrations:
- **Instance commands** — schema and data migrations that run once at the instance level.
- **Workspace commands** — commands that iterate over all active/suspended workspaces.
See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation.
### Instance Commands
- **When changing a `*.entity.ts` file**, generate an instance command:
- **When changing an entity, always generate a migration**
- If you modify a `*.entity.ts` file in `packages/twenty-server/src`, you **must** generate a corresponding TypeORM migration instead of manually editing the database schema.
- Use the Nx + TypeORM command from the project root:
```bash
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
```
- **Fast commands** (`--type fast`, default) are for schema-only changes that must run immediately. They implement `FastInstanceCommand` with `up`/`down` methods and use the `@RegisteredInstanceCommand` decorator.
- Replace `[name]` with a descriptive, kebab-case migration name that reflects the change (for example, `add-agent-turn-evaluation`).
- **Slow commands** (`--type slow`) add a `runDataMigration` method for potentially long-running data backfills that execute before `up`. They only run when `--include-slow` is passed. Use the decorator with `{ type: 'slow' }`.
- **Prefer generated migrations over manual edits**
- Let TypeORM infer schema changes from the updated entities; only adjust the generated migration file manually if absolutely necessary (for example, for data backfills or complex constraints).
- Keep schema changes (DDL) in these generated migrations and avoid mixing in heavy data migrations unless there is a strong reason and clear comments.
- The generator auto-registers the command in `instance-commands.constant.ts` — do not edit that file manually.
- **Keep commands consistent and reversible**: include both `up` and `down` logic. Do not delete or rewrite existing, committed commands unless on a pre-release branch.
### Workspace Commands
- Use the `@RegisteredWorkspaceCommand` decorator alongside nest-commander's `@Command` decorator.
- Extend `ActiveOrSuspendedWorkspaceCommandRunner` and implement `runOnWorkspace`.
- The base class provides `--dry-run`, `--verbose`, and workspace filter options automatically.
### Execution Order
Within a given version, commands run in this order (timestamp-sorted within each group):
1. Instance fast commands
2. Instance slow commands (only with `--include-slow`)
3. Workspace commands
- **Keep migrations consistent and reversible**
- Ensure the generated migration includes both `up` and `down` logic that correctly applies and reverts the entity change when possible.
- Do not delete or rewrite existing, committed migrations unless you are explicitly working on a pre-release branch where history rewrites are allowed by team conventions.
@@ -1,53 +0,0 @@
name: Deploy Twenty App
description: Build and deploy a Twenty app to a remote instance
inputs:
api-url:
description: Base URL of the target Twenty instance (e.g. https://my.twenty.instance)
required: true
api-key:
description: API key or access token for the target instance
required: true
app-path:
description: Path to the app directory (relative to repo root). Defaults to repo root.
required: false
default: '.'
runs:
using: composite
steps:
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
cache: yarn
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn install --immutable
- name: Configure remote
shell: bash
run: |
mkdir -p ~/.twenty
node -e "
const fs = require('fs'), path = require('path'), os = require('os');
fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({
version: 1,
remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } }
}, null, 2));
"
env:
API_URL: ${{ inputs.api-url }}
API_KEY: ${{ inputs.api-key }}
- name: Deploy
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty deploy --remote target
@@ -1,53 +0,0 @@
name: Install Twenty App
description: Install (or upgrade) a Twenty app on a specific workspace
inputs:
api-url:
description: Base URL of the target Twenty instance (e.g. https://my.twenty.instance)
required: true
api-key:
description: API key or access token for the target workspace
required: true
app-path:
description: Path to the app directory (relative to repo root). Defaults to repo root.
required: false
default: '.'
runs:
using: composite
steps:
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
cache: yarn
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn install --immutable
- name: Configure remote
shell: bash
run: |
mkdir -p ~/.twenty
node -e "
const fs = require('fs'), path = require('path'), os = require('os');
fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({
version: 1,
remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } }
}, null, 2));
"
env:
API_URL: ${{ inputs.api-url }}
API_KEY: ${{ inputs.api-key }}
- name: Install
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty install --remote target
@@ -1,47 +0,0 @@
name: Spawn Twenty App Dev Test
description: >
Starts a Twenty all-in-one test instance (server, worker, database, redis)
using the twentycrm/twenty-app-dev Docker image on port 2021.
The server is available at http://localhost:2021 with seeded demo data.
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag for twenty-app-dev (e.g., "latest" or "v1.20.0").'
required: false
default: 'latest'
outputs:
server-url:
description: 'URL where the Twenty test server can be reached'
value: http://localhost:2021
api-key:
description: 'API key (type: API_KEY) for the seeded Twenty dev workspace'
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc
runs:
using: 'composite'
steps:
- name: Start twenty-app-dev-test container
shell: bash
run: |
docker run -d \
--name twenty-app-dev-test \
-p 2021:2021 \
-e NODE_PORT=2021 \
-e SERVER_URL=http://localhost:2021 \
twentycrm/twenty-app-dev:${{ inputs.twenty-version }}
echo "Waiting for Twenty test instance to become healthy…"
TIMEOUT=180
ELAPSED=0
until curl -sf http://localhost:2021/healthz > /dev/null 2>&1; do
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
echo "::error::Twenty did not become healthy within ${TIMEOUT}s"
docker logs twenty-app-dev-test 2>&1 | tail -80
exit 1
fi
sleep 3
ELAPSED=$((ELAPSED + 3))
echo " … waited ${ELAPSED}s"
done
echo "Twenty test instance is ready at http://localhost:2021 (took ~${ELAPSED}s)"
@@ -251,14 +251,6 @@ jobs:
rm -f /tmp/current-server.pid
fi
- name: Flush Redis between server runs
run: |
# Clear all Redis caches to prevent stale data from the current branch
# server contaminating the main branch server. Both servers share the
# same Redis instance, and CoreEntityCacheService/WorkspaceCacheService
# persist cached entities across process restarts.
redis-cli -h localhost -p 6379 FLUSHALL || echo "::warning::Failed to flush Redis"
- name: Checkout main branch
run: |
git stash
@@ -25,10 +25,12 @@ jobs:
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
create-app-e2e-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
@@ -53,8 +55,6 @@ jobs:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -142,24 +142,27 @@ jobs:
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
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: Authenticate with twenty-server
env:
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
- name: Deploy scaffolded app
run: |
@@ -186,6 +189,8 @@ jobs:
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
@@ -23,10 +23,12 @@ jobs:
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
create-app-e2e-minimal:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
@@ -51,8 +53,6 @@ jobs:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -136,26 +136,41 @@ jobs:
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
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: Authenticate with twenty-server
env:
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
@@ -25,10 +25,12 @@ jobs:
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
create-app-e2e-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
@@ -53,8 +55,6 @@ jobs:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -140,24 +140,27 @@ jobs:
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
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: Authenticate with twenty-server
env:
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
- name: Deploy scaffolded app
run: |
@@ -184,6 +187,8 @@ jobs:
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
@@ -1,94 +0,0 @@
name: CI Example App Hello World
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-apps/examples/hello-world/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
example-app-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK packages
run: npx nx build twenty-sdk
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
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";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Run integration tests
working-directory: packages/twenty-apps/examples/hello-world
run: npx vitest run
ci-example-app-hello-world-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, example-app-hello-world]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -1,94 +0,0 @@
name: CI Example App Postcard
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-apps/examples/postcard/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
example-app-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK packages
run: npx nx build twenty-sdk
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
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";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Run integration tests
working-directory: packages/twenty-apps/examples/postcard
run: npx vitest run
ci-example-app-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, example-app-postcard]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+1 -1
View File
@@ -158,7 +158,7 @@ jobs:
timeout-minutes: 30
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=6144'
NODE_OPTIONS: '--max-old-space-size=4096'
TASK_CACHE_KEY: front-task-${{ matrix.task }}
strategy:
matrix:
+7 -19
View File
@@ -19,7 +19,6 @@ jobs:
files: |
packages/twenty-sdk/**
packages/twenty-server/**
.github/workflows/ci-sdk.yaml
!packages/twenty-sdk/package.json
sdk-test:
needs: changed-files-check
@@ -70,8 +69,7 @@ jobs:
ports:
- 6379:6379
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
NODE_ENV: test
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -79,26 +77,16 @@ jobs:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK
- name: Build
run: npx nx build twenty-sdk
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
- name: Server / Create Test DB
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";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server > /tmp/twenty-server.log 2>&1 &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: SDK / Run e2e Tests
run: NODE_ENV=test npx vitest run --config ./vitest.e2e.config.ts
working-directory: packages/twenty-sdk
- name: Server / Dump logs on failure
if: failure()
run: tail -100 /tmp/twenty-server.log || echo "No server log file found"
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: test:e2e
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
+7 -10
View File
@@ -144,18 +144,15 @@ jobs:
exit 1
- name: Server / Check for Pending Migrations
run: |
npx nx database:migrate:generate twenty-server -- --name pending-migration-check || true
CORE_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate core-migration-check -d src/database/typeorm/core/core.datasource.ts || true)
if ! git diff --quiet; then
echo "::error::Unexpected migration files were generated. Please run 'npx nx database:migrate:generate twenty-server -- --name <migration-name>' and commit the result."
echo ""
echo "The following migration changes were detected:"
echo "==================================================="
git diff
echo "==================================================="
echo ""
CORE_MIGRATION_FILE=$(ls packages/twenty-server/*core-migration-check.ts 2>/dev/null || echo "")
git checkout -- .
if [ -n "$CORE_MIGRATION_FILE" ]; then
echo "::error::Unexpected migration files were generated. Please create a proper migration manually."
echo "$CORE_MIGRATION_OUTPUT"
rm -f packages/twenty-server/*core-migration-check.ts
exit 1
fi
+3 -4
View File
@@ -69,14 +69,13 @@ jobs:
- name: Server / Start
run: |
npx nx run twenty-server:start:ci &
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -sf http://localhost:3000/healthz; do sleep 2; done'
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start worker
working-directory: packages/twenty-server
run: |
NODE_ENV=development node dist/queue-worker/queue-worker.js &
npx nx run twenty-server:worker &
echo "Worker started"
- name: Zapier / Build
-1
View File
@@ -53,4 +53,3 @@ mcp.json
TRANSLATION_QA_REPORT.md
.playwright-mcp/
.playwright-cli/
output/playwright/
+13 -13
View File
@@ -71,10 +71,13 @@ npx nx build twenty-server
# Database management
npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run instance commands (fast only)
npx nx run twenty-server:database:migrate:prod # Run migrations
# Generate an instance command (fast or slow)
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
# Generate migration (replace [name] with kebab-case descriptive name)
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
# Sync metadata
npx nx run twenty-server:command workspace:sync-metadata
```
### Database Inspection (Postgres MCP)
@@ -84,7 +87,7 @@ A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation issues
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
@@ -158,17 +161,14 @@ packages/
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database & Upgrade Commands
### Database & Migrations
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **ClickHouse** for analytics (when enabled)
- When changing entity files, generate an **instance command** (`database:migrate:generate --name <name> --type <fast|slow>`)
- **Fast** instance commands handle schema changes; **slow** ones add a `runDataMigration` step for data backfills
- **Workspace commands** iterate over all active/suspended workspaces for per-workspace upgrades
- Commands use `@RegisteredInstanceCommand` and `@RegisteredWorkspaceCommand` decorators for automatic discovery
- Include both `up` and `down` logic in instance commands
- Never delete or rewrite committed instance command `up`/`down` logic
- See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation
- Always generate migrations when changing entity files
- Migration names must be kebab-case (e.g. `add-agent-turn-evaluation`)
- Include both `up` and `down` logic in migrations
- Never delete or rewrite committed migrations
### Utility Helpers
Use existing helpers from `twenty-shared` instead of manual type guards:
@@ -181,7 +181,7 @@ IMPORTANT: Use Context7 for code generation, setup or configuration steps, or li
### Before Making Changes
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
2. Test changes with relevant test suites (prefer single-file test runs)
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
3. Ensure database migrations are generated for entity changes
4. Check that GraphQL schema changes are backward compatible
5. Run `graphql:generate` after any GraphQL schema changes
+1 -1
View File
@@ -141,7 +141,7 @@
"cache": false,
"options": {
"cwd": "{projectRoot}",
"command": "node -e \"const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='{args.releaseVersion}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');\""
"command": "npm pkg set version={args.releaseVersion}"
}
},
"storybook:build": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "1.22.0",
"version": "0.8.0-canary.9",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -6,16 +6,9 @@ on:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -23,11 +16,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
- name: Spawn Twenty instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
@@ -36,7 +30,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
@@ -45,4 +39,4 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
@@ -1,42 +0,0 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:3000
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -7,7 +7,9 @@
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
@@ -0,0 +1,70 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(
`Build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
});
afterAll(async () => {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
});
it('should find the installed app in the applications list', async () => {
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const installedApp = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(installedApp).toBeDefined();
});
});
@@ -1,87 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,46 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});
@@ -0,0 +1,45 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.writeFileSync(
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
);
});
@@ -1,15 +1,6 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY =
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
@@ -20,12 +11,11 @@ export default defineConfig({
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
},
});
@@ -21,7 +21,7 @@ export const copyBaseApplicationProject = async ({
console.log(chalk.gray('Generating application project...'));
await fs.copy(join(__dirname, './constants/template'), appDirectory);
await renameDotfiles({ appDirectory });
await renameGitignore({ appDirectory });
await generateUniversalIdentifiers({
appDisplayName,
@@ -32,20 +32,11 @@ export const copyBaseApplicationProject = async ({
await updatePackageJson({ appName, appDirectory });
};
// npm strips dotfiles/dotdirs (.gitignore, .github/) from published packages,
// so we store them without the leading dot and rename after copying.
const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
const renames = [
{ from: 'gitignore', to: '.gitignore' },
{ from: 'github', to: '.github' },
];
const renameGitignore = async ({ appDirectory }: { appDirectory: string }) => {
const gitignorePath = join(appDirectory, 'gitignore');
for (const { from, to } of renames) {
const sourcePath = join(appDirectory, from);
if (await fs.pathExists(sourcePath)) {
await fs.rename(sourcePath, join(appDirectory, to));
}
if (await fs.pathExists(gitignorePath)) {
await fs.rename(gitignorePath, join(appDirectory, '.gitignore'));
}
};
@@ -2,18 +2,22 @@
Updates Last interaction and Interaction status fields based on last email date
## Requirements
- an `apiKey` - go to Settings > API & Webhooks to generate one
## Setup
Add and synchronize app
1. Add and synchronize app
```bash
cd packages/twenty-apps/community/last-email-interaction
yarn twenty remote add
yarn twenty install
yarn auth
yarn sync
```
2. Go to Settings > Integrations > Last email interaction > Settings and add required variables
## Flow
- Extracts the datetime of fetched message and calculates the last interaction status
- Fetches all users and companies connected to the message and updates their Last interaction and Interaction status fields
- Checks if fields are created, if not, creates them on fly
- Extracts the datetime of message and calculates the last interaction status
- Fetches all users and companies connected to them and updates their Last interaction and Interaction status fields
## Notes
- Upon install, creates fields to Person and Company objects
- Every day at midnight app goes through all companies and people records and updates their Interaction status based on Last interaction date
## Todo:
- update app with generated Twenty object once extending objects is possible
@@ -0,0 +1,23 @@
import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: '718ed9ab-53fc-49c8-8deb-0cff78ecf0d2',
displayName: 'Last email interaction',
description:
'Updates Last interaction and Interaction status fields based on last received email',
icon: "IconMailFast",
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'aae3f523-4c1f-4805-b3ee-afeb676c381e',
isSecret: true,
description: 'Required to send requests to Twenty',
},
TWENTY_API_URL: {
universalIdentifier: '6d19bb04-45bb-46aa-a4e5-4a2682c7b19d',
isSecret: false,
description: 'Optional, defaults to cloud API URL',
},
},
};
export default config;
@@ -9,23 +9,19 @@
},
"packageManager": "yarn@4.9.2",
"dependencies": {
"axios": "1.14.0",
"twenty-sdk": "0.8.0"
},
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
"axios": "^1.12.2",
"twenty-sdk": "0.2.4"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
"@types/node": "^24.7.2"
},
"scripts": {
"auth": "twenty auth login",
"dev": "twenty app dev",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
"logs": "twenty app logs",
"create-entity": "twenty app add",
"help": "twenty --help"
}
}
@@ -1,10 +0,0 @@
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: '718ed9ab-53fc-49c8-8deb-0cff78ecf0d2',
displayName: 'Last email interaction',
description:
'Updates Last interaction and Interaction status fields based on last received email',
icon: "IconMailFast",
defaultRoleUniversalIdentifier: '7a66af97-5056-45b2-96a9-c89f0fd181d1',
});
@@ -1,41 +0,0 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
export default defineField({
universalIdentifier: '9378751e-c23b-4e84-887d-2905cb8359b4',
name: 'interactionStatus',
label: 'Interaction status',
type: FieldType.SELECT,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
description: 'Indicates the health of relation',
options: [
{
id: '39d54a6b-5a0e-4209-9a59-2a2d7b8c462b',
color: 'green',
label: 'Recent',
value: 'RECENT',
position: 1,
},
{
id: '7377d6c5-a75c-453e-a1a1-63fb9cba4e26',
color: 'yellow',
label: 'Active',
value: 'ACTIVE',
position: 2,
},
{
id: 'a8b99246-237f-4715-b21f-94a3ae14994e',
color: 'sky',
label: 'Cooling',
value: 'COOLING',
position: 3,
},
{
id: '1f05d528-eaab-4639-aba1-328050a87220',
color: 'gray',
label: 'Dormant',
value: 'DORMANT',
position: 4,
},
],
});
@@ -1,14 +0,0 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: '2f195c4c-1db1-4bbe-80b6-25c2f63168b0',
name: 'lastInteraction',
label: 'Last interaction',
type: FieldType.DATE_TIME,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
description: 'Date when the last interaction happened',
});
@@ -1,41 +0,0 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
export default defineField({
universalIdentifier: 'fa342e26-9742-4db8-85b4-4d78ba18482f',
name: 'interactionStatus',
label: 'Interaction status',
type: FieldType.SELECT,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
description: 'Indicates the health of relation',
options: [
{
id: '1dfbfa99-35fb-43af-8c14-74e682a8121b',
color: 'green',
label: 'Recent',
value: 'RECENT',
position: 1,
},
{
id: '955788e8-6d64-45ba-80ea-a1a5446a0ae7',
color: 'yellow',
label: 'Active',
value: 'ACTIVE',
position: 2,
},
{
id: '7b84ca72-fac5-4c6d-ab08-b148e4b3efdf',
color: 'sky',
label: 'Cooling',
value: 'COOLING',
position: 3,
},
{
id: '04dea3e5-ec26-41cf-b23f-37abab67827a',
color: 'gray',
label: 'Dormant',
value: 'DORMANT',
position: 4,
},
],
});
@@ -1,14 +0,0 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'bec14de7-6683-4784-91ba-62d83b5f30f7',
name: 'lastInteraction',
label: 'Last interaction',
type: FieldType.DATE_TIME,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
description: 'Date when the last interaction happened',
});
@@ -0,0 +1,270 @@
import axios from 'axios';
import { type FunctionConfig } from 'twenty-sdk';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '';
const TWENTY_URL =
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
? `${process.env.TWENTY_API_URL}/rest`
: 'https://api.twenty.com/rest';
const create_last_interaction = (id: string) => {
return {
method: 'POST',
url: `${TWENTY_URL}/metadata/fields`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
data: {
type: 'DATE_TIME',
objectMetadataId: `${id}`,
name: 'lastInteraction',
label: 'Last interaction',
description: 'Date when the last interaction happened',
icon: 'IconCalendarClock',
defaultValue: null,
isNullable: true,
settings: {},
},
};
};
const create_interaction_status = (id: string) => {
return {
method: 'POST',
url: `${TWENTY_URL}/metadata/fields`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
data: {
type: 'SELECT',
objectMetadataId: `${id}`,
name: 'interactionStatus',
label: 'Interaction status',
description: 'Indicates the health of relation',
icon: 'IconProgress',
defaultValue: null,
isNullable: true,
settings: {},
options: [
{
color: 'green',
label: 'Recent',
value: 'RECENT',
position: 1,
},
{
color: 'yellow',
label: 'Active',
value: 'ACTIVE',
position: 2,
},
{
color: 'sky',
label: 'Cooling',
value: 'COOLING',
position: 3,
},
{
color: 'gray',
label: 'Dormant',
value: 'DORMANT',
position: 4,
},
],
},
};
};
const calculateStatus = (date: string) => {
const day = 1000 * 60 * 60 * 24;
const now = Date.now();
const messageDate = Date.parse(date);
const deltaTime = now - messageDate;
return deltaTime < 7 * day
? 'RECENT'
: deltaTime < 30 * day
? 'ACTIVE'
: deltaTime < 90 * day
? 'COOLING'
: 'DORMANT';
};
const updateInteractionStatus = async (objectName: string, id: string, messageDate: string, status: string) => {
const options = {
method: 'PATCH',
url: `${TWENTY_URL}/${objectName}/${id}`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
data: {
lastInteraction: messageDate,
interactionStatus: status
}
};
try {
const response = await axios.request(options);
if (response.status === 200) {
console.log('Successfully updated company last interaction field');
}
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
throw error;
}
}
const fetchRelatedCompanyId = async (id: string) => {
const options = {
method: 'GET',
url: `${TWENTY_URL}/people/${id}`,
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
};
try {
const req = await axios.request(options);
if (req.status === 200 && req.data.person.companyId !== null && req.data.person.companyId !== undefined) {
return req.data.person.companyId;
}
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
throw error;
}
}
export const main = async (params: {
properties: Record<string, any>;
recordId: string;
userId: string;
}): Promise<object | undefined> => {
if (TWENTY_API_KEY === '') {
console.log("Function exited as API key or URL hasn't been set properly");
return {};
}
const { properties, recordId } = params;
// Check if fields are created
const options = {
method: 'GET',
url: `${TWENTY_URL}/metadata/objects`,
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
};
try {
const response = await axios.request(options);
const objects = response.data.data.objects;
const company_object = objects.find(
(object: any) => object.nameSingular === 'company',
);
const company_last_interaction = company_object.fields.find(
(field: any) => field.name === 'lastInteraction',
);
const company_interaction_status = company_object.fields.find(
(field: any) => field.name === 'interactionStatus',
);
const person_object = objects.find(
(object: any) => object.nameSingular === 'person',
);
const person_last_interaction = person_object.fields.find(
(field: any) => field.name === 'lastInteraction',
);
const person_interaction_status = person_object.fields.find(
(field: any) => field.name === 'interactionStatus',
);
// If not, create them
if (company_last_interaction === undefined) {
const response2 = await axios.request(
create_last_interaction(company_object.id),
);
if (response2.status === 201) {
console.log('Successfully created company last interaction field');
}
}
if (company_interaction_status === undefined) {
const response2 = await axios.request(
create_interaction_status(company_object.id),
);
if (response2.status === 201) {
console.log('Successfully created company interaction status field');
}
}
if (person_last_interaction === undefined) {
const response2 = await axios.request(
create_last_interaction(person_object.id),
);
if (response2.status === 201) {
console.log('Successfully created person last interaction field');
}
}
if (person_interaction_status === undefined) {
const response2 = await axios.request(
create_interaction_status(person_object.id),
);
if (response2.status === 201) {
console.log('Successfully created person interaction status field');
}
}
// Extract the timestamp of message
const messageDate = properties.after.receivedAt;
const interactionStatus = calculateStatus(messageDate);
// Get the details of person and related company
const messageOptions = {
method: 'GET',
url: `${TWENTY_URL}/messages/${recordId}?depth=1`,
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
};
const messageDetails = await axios.request(messageOptions);
const peopleIds: string[] = [];
for (const participant of messageDetails.data.messages
.messageParticipants) {
peopleIds.push(participant.personId);
}
const companiesIds = [];
for (const id of peopleIds) {
companiesIds.push(await fetchRelatedCompanyId(id));
}
// Update the field value depending on the timestamp
for (const id of peopleIds) {
await updateInteractionStatus("people", id, messageDate, interactionStatus);
}
for (const id of companiesIds) {
await updateInteractionStatus("companies", id, messageDate, interactionStatus);
}
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.message);
return {};
}
console.error(error);
return {};
}
};
export const config: FunctionConfig = {
universalIdentifier: '683966a0-b60a-424e-86b1-7448c9191bde',
name: 'test',
triggers: [
{
universalIdentifier: 'f4f1e127-87f0-4dcf-99fe-8061adf5cbe6',
type: 'databaseEvent',
eventName: 'message.created',
},
{
universalIdentifier: '4c17878f-b6b3-4d0a-8de6-967b1cb55002',
type: 'databaseEvent',
eventName: 'message.updated',
},
],
};
@@ -1,126 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { calculateStatus } from '../shared/calculate-status';
const fetchAllPeople = async () => {
const client = new CoreApiClient();
const result = await client.query({
people: {
__args: {
filter: {
/*
lastInteraction: {
is: 'NOT_NULL',
},
*/
},
},
edges: {
node: {
id: true,
lastInteraction: true,
interactionStatus: true,
},
},
},
});
if (!result.people) {
throw new Error('Could not find any people');
}
return result.people.edges;
}
const fetchAllCompanies = async () => {
const client = new CoreApiClient();
const result = await client.query({
companies: {
__args: {
filter: {
/* how to fetch fields added in fields folder?
lastInteraction: {
is: 'NOT_NULL',
},
*/
},
},
edges: {
node: {
id: true,
lastInteraction: true,
interactionStatus: true,
},
},
},
});
if (!result.companies) {
throw new Error('Could not find any companies');
}
return result.companies.edges;
}
const updateCompany = async (
companyId: string,
updateData: Record<string, string>,
) => {
const client = new CoreApiClient();
const result = await client.mutation({
updateCompany: {
__args: {
id: companyId,
data: updateData,
},
id: true,
},
});
if (!result.updateCompany) {
throw new Error(`Failed to update company ${companyId}`);
}
};
const updatePerson = async (
personId: string,
updateData: Record<string, string>,
) => {
const client = new CoreApiClient();
const result = await client.mutation({
updatePerson: {
__args: {
id: personId,
data: updateData,
},
id: true,
},
});
if (!result.updatePerson) {
throw new Error(`Failed to update person ${personId}`);
}
};
const handler = async () => {
const people = await fetchAllPeople();
for (const person of people) {
const interactionStatus = calculateStatus(person.node.lastInteraction as string);
if (interactionStatus !== person.node.interactionStatus) {
await updatePerson(person.node.id, {interactionStatus: interactionStatus});
}
}
const companies = await fetchAllCompanies();
for (const company of companies) {
const interactionStatus = calculateStatus(company.node.lastInteraction as string);
if (interactionStatus !== company.node.interactionStatus) {
await updateCompany(company.node.id, {interactionStatus: interactionStatus});
}
}
};
export default defineLogicFunction({
universalIdentifier: 'c79f1f30-f369-4264-9e5b-c183577bc709',
name: 'on-cron-job',
description:
'Runs daily at midnight and updates all companies and people with correct interaction status',
timeoutSeconds: 5,
handler,
cronTriggerSettings: {
pattern: '0 0 * * *', // runs daily at midnight
},
});
@@ -1,143 +0,0 @@
import {
DatabaseEventPayload,
defineLogicFunction,
ObjectRecordCreateEvent,
} from 'twenty-sdk';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { calculateStatus } from '../shared/calculate-status';
const fetchMessageParticipants = async (messageId: string) => {
const client = new CoreApiClient();
const result = await client.query({
messageParticipants: {
__args: {
filter: {
messageId: {
eq: messageId,
},
},
},
edges: {
node: {
personId: true,
},
},
},
});
let people: string[] = [];
if (result.messageParticipants === undefined) {
return people;
}
for (const person of result.messageParticipants.edges) {
if (person.node.personId !== undefined) {
people.push(person.node.personId);
}
}
return people;
};
const fetchRelatedCompany = async (personId: string) => {
const client = new CoreApiClient();
const result = await client.query({
people: {
__args: {
filter: {
id: {
eq: personId,
},
},
},
edges: {
node: {
company: {
id: true,
},
},
},
},
});
if (
result.people === undefined ||
result.people.edges[0].node.company === undefined
) {
throw new Error(`Failed to fetch related company of person ${personId}`);
}
return result.people.edges[0].node.company.id;
};
const updateCompany = async (
companyId: string,
updateData: Record<string, string>,
) => {
const client = new CoreApiClient();
const result = await client.mutation({
updateCompany: {
__args: {
id: companyId,
data: updateData,
},
id: true,
},
});
if (!result.updateCompany) {
throw new Error(`Failed to update company ${companyId}`);
}
};
const updatePerson = async (
personId: string,
updateData: Record<string, string>,
) => {
const client = new CoreApiClient();
const result = await client.mutation({
updatePerson: {
__args: {
id: personId,
data: updateData,
},
id: true,
},
});
if (!result.updatePerson) {
throw new Error(`Failed to update person ${personId}`);
}
};
type Message = {
receivedAt: string;
};
type MessageCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Message>
>;
const handler = async (
event: MessageCreatedEvent,
): Promise<object | undefined> => {
const { properties, recordId } = event;
const interactionStatus = calculateStatus(properties.after.receivedAt);
const peopleIds: string[] = [];
peopleIds.push(...(await fetchMessageParticipants(recordId)));
const updateData = {
lastInteraction: properties.after.receivedAt,
interactionStatus: interactionStatus,
};
for (const person of peopleIds) {
const companyId = await fetchRelatedCompany(person);
await updatePerson(person, updateData);
await updateCompany(companyId, updateData);
}
return {};
};
export default defineLogicFunction({
universalIdentifier: '543432c2-6509-4ee9-90ec-15ffb4d7abfc',
name: 'on-message-created',
description: 'Triggered when new message is created',
timeoutSeconds: 5,
handler,
databaseEventTriggerSettings: {
eventName: 'message.created',
},
});
@@ -1,143 +0,0 @@
import {
DatabaseEventPayload,
defineLogicFunction,
ObjectRecordCreateEvent,
} from 'twenty-sdk';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { calculateStatus } from '../shared/calculate-status';
const fetchMessageParticipants = async (messageId: string) => {
const client = new CoreApiClient();
const result = await client.query({
messageParticipants: {
__args: {
filter: {
messageId: {
eq: messageId,
},
},
},
edges: {
node: {
personId: true,
},
},
},
});
let people: string[] = [];
if (result.messageParticipants === undefined) {
return people;
}
for (const person of result.messageParticipants.edges) {
if (person.node.personId !== undefined) {
people.push(person.node.personId);
}
}
return people;
};
const fetchRelatedCompany = async (personId: string) => {
const client = new CoreApiClient();
const result = await client.query({
people: {
__args: {
filter: {
id: {
eq: personId,
},
},
},
edges: {
node: {
company: {
id: true,
},
},
},
},
});
if (
result.people === undefined ||
result.people.edges[0].node.company === undefined
) {
throw new Error(`Failed to fetch related company of person ${personId}`);
}
return result.people.edges[0].node.company.id;
};
const updateCompany = async (
companyId: string,
updateData: Record<string, string>,
) => {
const client = new CoreApiClient();
const result = await client.mutation({
updateCompany: {
__args: {
id: companyId,
data: updateData,
},
id: true,
},
});
if (!result.updateCompany) {
throw new Error(`Failed to update company ${companyId}`);
}
};
const updatePerson = async (
personId: string,
updateData: Record<string, string>,
) => {
const client = new CoreApiClient();
const result = await client.mutation({
updatePerson: {
__args: {
id: personId,
data: updateData,
},
id: true,
},
});
if (!result.updatePerson) {
throw new Error(`Failed to update person ${personId}`);
}
};
type Message = {
receivedAt: string;
};
type MessageCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Message>
>;
const handler = async (
event: MessageCreatedEvent,
): Promise<object | undefined> => {
const { properties, recordId } = event;
const interactionStatus = calculateStatus(properties.after.receivedAt);
const peopleIds: string[] = [];
peopleIds.push(...(await fetchMessageParticipants(recordId)));
const updateData = {
lastInteraction: properties.after.receivedAt,
interactionStatus: interactionStatus,
};
for (const person of peopleIds) {
const companyId = await fetchRelatedCompany(person);
await updatePerson(person, updateData);
await updateCompany(companyId, updateData);
}
return {};
};
export default defineLogicFunction({
universalIdentifier: '9bfcb3e4-9119-4b65-b6e9-d395d0764ce5',
name: 'on-message-updated',
description: 'Triggered when message is updated',
timeoutSeconds: 5,
handler,
databaseEventTriggerSettings: {
eventName: 'message.updated',
},
});
@@ -1,13 +0,0 @@
export const calculateStatus = (date: string) => {
const day = 1000 * 60 * 60 * 24;
const now = Date.now();
const messageDate = Date.parse(date);
const deltaTime = now - messageDate;
return deltaTime < 7 * day
? 'RECENT'
: deltaTime < 30 * day
? 'ACTIVE'
: deltaTime < 90 * day
? 'COOLING'
: 'DORMANT';
};
@@ -1,17 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"dist"
]
}
File diff suppressed because it is too large Load Diff
@@ -1,42 +0,0 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:3000
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -6,16 +6,9 @@ on:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -23,11 +16,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
- name: Spawn Twenty instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
@@ -36,7 +30,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
@@ -45,4 +39,4 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
@@ -16,8 +16,8 @@
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "0.9.0",
"twenty-sdk": "0.9.0"
"twenty-client-sdk": "0.8.0-canary.5",
"twenty-sdk": "0.8.0-canary.5"
},
"devDependencies": {
"@types/node": "^24.7.2",
@@ -3,51 +3,43 @@ import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
beforeAll(async () => {
const apiUrl = process.env.TWENTY_API_URL!;
const token = process.env.TWENTY_API_KEY!;
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
fs.mkdirSync(CONFIG_DIR, { recursive: true });
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.writeFileSync(
CONFIG_PATH,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey: token },
},
defaultRemote: 'local',
},
null,
2,
),
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
);
process.env.TWENTY_APP_ACCESS_TOKEN ??= token;
});
@@ -1,19 +1,13 @@
import {
definePostInstallLogicFunction,
type InstallLogicFunctionPayload,
} from 'twenty-sdk';
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log(
'Post install logic function executed successfully!',
payload.previousVersion,
);
console.log('Post install logic function executed successfully!', payload.previousVersion);
};
export default definePostInstallLogicFunction({
universalIdentifier: '8c726dcc-1709-4eac-aa8b-f99960a9ec1b',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 30,
timeoutSeconds: 300,
handler,
});
@@ -14,10 +14,9 @@ export default defineConfig({
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_API_KEY:
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
},
});
File diff suppressed because it is too large Load Diff
@@ -1,42 +0,0 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:3000
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -6,16 +6,9 @@ on:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -23,11 +16,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
- name: Spawn Twenty instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
@@ -36,7 +30,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
@@ -45,4 +39,4 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
@@ -19,8 +19,8 @@
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "0.9.0",
"twenty-sdk": "0.9.0"
"twenty-client-sdk": "0.8.0-canary.8",
"twenty-sdk": "0.8.0-canary.8"
},
"devDependencies": {
"@types/node": "^24.7.2",
@@ -1,7 +1,7 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
const APP_PATH = process.cwd();
@@ -44,9 +44,7 @@ describe('App installation', () => {
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${
uninstallResult.error?.message ?? 'Unknown error'
}`,
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
});
@@ -1,87 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,63 +0,0 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('PostCard object', () => {
it('should exist with expected fields and relations', async () => {
const client = new MetadataApiClient();
const { objects } = await client.query({
objects: {
__args: {
filter: { isCustom: { is: true } },
paging: { first: 50 },
},
edges: {
node: {
nameSingular: true,
fields: {
__args: { paging: { first: 500 } },
edges: { node: { name: true } },
},
},
},
},
});
const obj = objects.edges
.map((e: { node: { nameSingular: string } }) => e.node)
.find((n: { nameSingular: string }) => n.nameSingular === 'postCard');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map(
(e: { node: { name: string } }) => e.node.name,
);
expect(names).toContain('name');
expect(names).toContain('content');
expect(names).toContain('status');
expect(names).toContain('deliveredAt');
expect(names).toContain('recipient');
});
});
@@ -0,0 +1,45 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.writeFileSync(
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
);
});
@@ -1,35 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { definePostInstallLogicFunction } from 'twenty-sdk';
const SEED_POST_CARDS = [
{
name: 'Greetings from Paris',
content: 'Wish you were here! The Eiffel Tower is breathtaking.',
},
{
name: 'Hello from Tokyo',
content: 'Cherry blossoms are in full bloom. Sending love!',
},
];
const handler = async () => {
const client = new CoreApiClient();
await client.mutation({
createPostCards: {
__args: { data: SEED_POST_CARDS as any },
id: true,
},
} as any);
console.log(`Seeded ${SEED_POST_CARDS.length} post cards on install.`);
return {};
};
export default definePostInstallLogicFunction({
universalIdentifier: '852c6321-1563-4396-b7c5-9d370f3d30a9',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 30,
handler,
});
@@ -1,17 +0,0 @@
import { definePreInstallLogicFunction } from 'twenty-sdk';
const handler = async (params: any) => {
console.log(
`Pre-install logic function executed successfully with params`,
params,
);
return {};
};
export default definePreInstallLogicFunction({
universalIdentifier: 'bf27f558-4ec6-481f-b76e-1dbcd05aef1f',
name: 'pre-install',
description: 'Runs before migrations to set up the application.',
timeoutSeconds: 10,
handler,
});
@@ -51,28 +51,28 @@ export default defineObject({
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{
id: '8ab3abad-02e7-4670-9283-983d7fac7fe4',
id: 'a1b2c3d4-0001-4000-8000-000000000001',
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: '2bcdd195-6c99-4d69-84b2-e2838ee54467',
id: 'a1b2c3d4-0002-4000-8000-000000000002',
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
id: '918ff60c-c26e-4fae-8eba-3fbce04dc48b',
id: 'a1b2c3d4-0003-4000-8000-000000000003',
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
id: '3c91a653-5d31-4023-be6c-a69c68d21233',
id: 'a1b2c3d4-0004-4000-8000-000000000004',
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
@@ -33,7 +33,7 @@ export default defineRole({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
canReadFieldValue: false,
canUpdateFieldValue: true,
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
@@ -1,15 +1,6 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY =
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
@@ -20,12 +11,11 @@ export default defineConfig({
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
},
});
@@ -3317,8 +3317,8 @@ __metadata:
oxlint: "npm:^0.16.0"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
twenty-client-sdk: "npm:0.9.0"
twenty-sdk: "npm:0.9.0"
twenty-client-sdk: "npm:0.8.0-canary.8"
twenty-sdk: "npm:0.8.0-canary.8"
typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:^3.1.1"
@@ -4059,21 +4059,21 @@ __metadata:
languageName: node
linkType: hard
"twenty-client-sdk@npm:0.9.0":
version: 0.9.0
resolution: "twenty-client-sdk@npm:0.9.0"
"twenty-client-sdk@npm:0.8.0-canary.8":
version: 0.8.0-canary.8
resolution: "twenty-client-sdk@npm:0.8.0-canary.8"
dependencies:
"@genql/cli": "npm:^3.0.3"
"@genql/runtime": "npm:^2.10.0"
esbuild: "npm:^0.25.0"
graphql: "npm:^16.8.1"
checksum: 10c0/4b42a6622a9852fc3eca50c1131b116c5602af006ee5f1d3b4f1e97721bbc8ef5c2f3b60d9dee3ca9ca8c3c7ad4b292a7b55007b779b4c042997dbc29940ba54
checksum: 10c0/acb5bf952a9729811235a3474ccb4db82630c53a2caed03176bfb4f442576ee2ef7e625886f63714e2534f7ec83b03d83e4c0b1170a0eb816c531acfc2c98010
languageName: node
linkType: hard
"twenty-sdk@npm:0.9.0":
version: 0.9.0
resolution: "twenty-sdk@npm:0.9.0"
"twenty-sdk@npm:0.8.0-canary.8":
version: 0.8.0-canary.8
resolution: "twenty-sdk@npm:0.8.0-canary.8"
dependencies:
"@genql/cli": "npm:^3.0.3"
"@genql/runtime": "npm:^2.10.0"
@@ -4093,7 +4093,7 @@ __metadata:
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
tinyglobby: "npm:^0.2.15"
twenty-client-sdk: "npm:0.9.0"
twenty-client-sdk: "npm:0.8.0-canary.8"
typescript: "npm:^5.9.2"
uuid: "npm:^13.0.0"
vite: "npm:^7.0.0"
@@ -4101,7 +4101,7 @@ __metadata:
zod: "npm:^4.1.11"
bin:
twenty: dist/cli.cjs
checksum: 10c0/27f93e5edac3265f819abacc853598435718020258a95d55518562e086e42daabae784964b42005d8e4ff30d1d6f13a12a38c656e18c60bad98da3f579a8e1c3
checksum: 10c0/a22cf071f41c19d769e68a995912a0bda0f087bd9a295534bf9e545544f6dfc84f284a477f9011fc135f2a44de9372b977a66de06454e760822119e76921ea69
languageName: node
linkType: hard
@@ -16,8 +16,7 @@
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"dependencies": {
"twenty-sdk": "latest",
"twenty-client-sdk": "latest"
"twenty-sdk": "latest"
},
"devDependencies": {
"typescript": "^5.9.3",
@@ -14,8 +14,7 @@
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"dependencies": {
"twenty-sdk": "latest",
"twenty-client-sdk": "latest"
"twenty-sdk": "latest"
},
"devDependencies": {
"typescript": "^5.9.3",
@@ -14,8 +14,7 @@
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"dependencies": {
"twenty-sdk": "latest",
"twenty-client-sdk": "latest"
"twenty-sdk": "latest"
},
"devDependencies": {
"typescript": "^5.9.3",
@@ -10,31 +10,25 @@ export default defineField({
description: 'Post card category',
options: [
{
id: 'cd751c81-787d-4581-bc51-efe43f0050a7',
id: 'c1d2e3f4-0001-4000-8000-000000000001',
value: 'PERSONAL',
label: 'Personal',
color: 'blue',
position: 0,
},
{
id: 'eec437ca-5beb-41a9-a826-c9a5eca2eef4',
id: 'c1d2e3f4-0002-4000-8000-000000000002',
value: 'BUSINESS',
label: 'Business',
color: 'green',
position: 1,
},
{
id: 'a5baa37d-1047-4972-b6b8-7faae0e3eac1',
id: 'c1d2e3f4-0003-4000-8000-000000000003',
value: 'PROMOTIONAL',
label: 'Promotional',
color: 'orange',
position: 2,
},
{
value: 'OTHER',
label: 'Other',
color: 'gray',
position: 3,
},
],
});
@@ -5,7 +5,6 @@ enum PostCardStatus {
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
LOST = 'LOST',
}
export const POST_CARD_UNIVERSAL_IDENTIFIER =
@@ -54,40 +53,33 @@ export default defineObject({
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{
id: '1b008e19-1e59-4a07-b187-65a20e547c4e',
id: 'a1b2c3d4-0001-4000-8000-000000000001',
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: '452b9d40-889c-4342-9697-98319394db04',
id: 'a1b2c3d4-0002-4000-8000-000000000002',
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
id: 'c2ed0b8c-a3ed-4383-aef9-e0441267bcfe',
id: 'a1b2c3d4-0003-4000-8000-000000000003',
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
id: 'c57a5e08-7ef7-49b8-87e6-32d720d22802',
id: 'a1b2c3d4-0004-4000-8000-000000000004',
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
{
// No id — exercises addMissingFieldOptionIds in the object branch
value: PostCardStatus.LOST,
label: 'Lost',
position: 4,
color: 'red',
},
],
name: 'status',
},
@@ -1,42 +0,0 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:3000
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -35,4 +35,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
*.d.ts
@@ -1 +0,0 @@
24.5.0
@@ -1,19 +1,40 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -1 +1,5 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
enableTransparentWorkspaces: false
@@ -1,11 +1,44 @@
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
# Self Hosting
## Getting Started
Used to manage billing and telemetry of self-hosted instances
Run `yarn twenty help` to list all available commands.
## Features
## Learn More
### Telemetry Webhook
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
Receives user signup telemetry events from self-hosted Twenty instances and creates/updates selfHostingUser records.
**Endpoint:** `POST /webhook/telemetry`
**Payload Structure:**
```json
{
"action": "user_signup",
"timestamp": "2025-11-21T...",
"version": "1",
"payload": {
"userId": "uuid",
"workspaceId": "uuid",
"payload": {
"events": [
{
"userEmail": "user@example.com",
"userId": "uuid",
"userFirstName": "John",
"userLastName": "Doe",
"locale": "en",
"serverUrl": "https://self-hosted.example.com"
}
]
}
}
}
```
**Response:**
```json
{
"success": true,
"message": "Self hosting user created/updated: uuid"
}
```
@@ -1,6 +1,6 @@
{
"name": "self-hosting",
"version": "1.0.0",
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -11,22 +11,13 @@
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "1.22.0-canary.6",
"twenty-sdk": "1.22.0-canary.6"
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
"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,53 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
beforeAll(async () => {
const apiUrl = process.env.TWENTY_API_URL!;
const token = process.env.TWENTY_API_KEY!;
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
CONFIG_PATH,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey: token },
},
defaultRemote: 'local',
},
null,
2,
),
);
process.env.TWENTY_APP_ACCESS_TOKEN ??= token;
});
@@ -1,9 +1,6 @@
import { defineApplication } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'94f7db30-59e5-4b09-a5fe-64cd3d4a65b0';
export default defineApplication({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
@@ -5,7 +5,7 @@ import {
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { SELF_HOSTING_USER_NAME_SINGULAR } from 'src/objects/selfHostingUser.object';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CoreApiClient } from 'twenty-sdk/clients';
type SelfHostingUser = {
id: string;
@@ -1,5 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CoreApiClient } from 'twenty-sdk/clients';
import { type TelemetryEvent } from 'src/logic-functions/types/telemetry-event.type';
export const main = async (
@@ -4,7 +4,7 @@ import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.const
export default defineRole({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.roles.defaultRole.universalIdentifier,
label: 'Self hosting default role',
label: 'default role',
description: 'Add a description for your role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
@@ -27,16 +27,5 @@
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -1,24 +0,0 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
process.env.TWENTY_API_KEY ??
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});
File diff suppressed because it is too large Load Diff
@@ -6,16 +6,9 @@ on:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -23,11 +16,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
- name: Spawn Twenty instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
@@ -36,7 +30,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
@@ -45,4 +39,4 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,11 @@
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
Run `yarn twenty help` to list all available commands.
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -0,0 +1,35 @@
{
"name": "my-twenty-app",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "0.8.0-canary.8",
"twenty-sdk": "0.8.0-canary.8"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
@@ -0,0 +1,70 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(
`Build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
});
afterAll(async () => {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
});
it('should find the installed app in the applications list', async () => {
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const installedApp = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(installedApp).toBeDefined();
});
});
@@ -0,0 +1,45 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.writeFileSync(
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
);
});

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