Compare commits

..
Author SHA1 Message Date
Marie Stoppa 41fcd692a6 Revert "Fix spaces in code step + suggestion overflow (#17664)"
This reverts commit 99aab9f40d.
2026-02-03 14:05:28 +01:00
MarieandMarie Stoppa b9bc1264b3 Allow twenty standard app to access messaging items (#17659) 2026-02-03 13:38:30 +01:00
Thomas TrompetteandMarie Stoppa be358e398e Fix spaces in code step + suggestion overflow (#17664)
- fixedOverflowWidgets fixes the suggestions being overflow outside the
editor
- on focus, stop event propagation to avoid catching spaces as document
level events
2026-02-03 13:38:22 +01:00
7662 changed files with 155353 additions and 376123 deletions
-34
View File
@@ -1,34 +0,0 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
curl \
git \
make \
build-essential \
postgresql-client \
docker.io \
&& rm -rf /var/lib/apt/lists/*
# Install nvm (project recommends nvm + .nvmrc for consistent Node versions)
ENV NVM_DIR=/usr/local/nvm
RUN mkdir -p $NVM_DIR \
&& curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
SHELL ["/bin/bash", "-c"]
# Copy .nvmrc so nvm install picks up the right version
COPY .nvmrc /tmp/.nvmrc
# Install Node.js from .nvmrc, enable Corepack, and symlink binaries
# so they're available on PATH without hardcoding a version
RUN . $NVM_DIR/nvm.sh \
&& nvm install $(cat /tmp/.nvmrc) \
&& nvm alias default $(cat /tmp/.nvmrc) \
&& corepack enable \
&& BIN_DIR=$(dirname $(nvm which default)) \
&& ln -sf $BIN_DIR/node /usr/local/bin/node \
&& ln -sf $BIN_DIR/npm /usr/local/bin/npm \
&& ln -sf $BIN_DIR/npx /usr/local/bin/npx \
&& ln -sf $BIN_DIR/corepack /usr/local/bin/corepack
+12 -4
View File
@@ -1,10 +1,18 @@
{
"install": "yarn install",
"start": "sudo service docker start && sleep 2 && (docker start twenty_pg 2>/dev/null || make -C packages/twenty-docker postgres-on-docker) && (docker start twenty_redis 2>/dev/null || make -C packages/twenty-docker redis-on-docker) && until docker exec twenty_pg pg_isready -U postgres -h localhost 2>/dev/null; do sleep 1; done && echo 'PostgreSQL ready' && until docker exec twenty_redis redis-cli ping 2>/dev/null | grep -q PONG; do sleep 1; done && echo 'Redis ready' && bash packages/twenty-utils/setup-dev-env.sh && npx nx database:reset twenty-server",
"install": "curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && sudo apt-get install -y nodejs && node --version && yarn install && echo 'Installing dependencies complete'",
"start": "sudo service docker start && echo 'Docker service started' && sleep 3 && echo 'Starting PostgreSQL and Redis containers...' && make postgres-on-docker && make redis-on-docker && echo 'Waiting for containers to initialize...' && sleep 20 && echo 'Checking container status...' && docker ps --filter name=twenty_ && echo 'Waiting for PostgreSQL to be ready...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'PostgreSQL not ready yet, waiting...'; sleep 3; done && echo 'PostgreSQL is ready!' && echo 'Setting up database...' && cd packages/twenty-server && npx nx database:reset twenty-server || echo 'Database already initialized' && echo 'Environment setup complete!'",
"terminals": [
{
"name": "Development Server",
"command": "yarn start"
"command": "echo 'Waiting for database to be fully ready...' && sleep 30 && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'Starting Twenty development server...' && export SERVER_URL=http://localhost:3000 && export PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres && yarn start"
},
{
"name": "Database Management",
"command": "sleep 25 && echo 'Database management terminal ready' && echo 'Waiting for PostgreSQL to be available...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'PostgreSQL is ready for database operations!' && echo 'You can now run database commands like:' && echo ' npx nx database:reset twenty-server' && echo ' npx nx database:migrate twenty-server' && bash"
},
{
"name": "Container Logs & Status",
"command": "sleep 10 && echo '=== Container Status Monitor ===' && while true; do echo '\\n=== Container Status at $(date) ===' && docker ps --filter name=twenty_ --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}' && echo '\\n=== PostgreSQL Status ===' && (docker exec twenty_pg pg_isready -U postgres -h localhost && echo 'PostgreSQL: ✅ Ready') || echo 'PostgreSQL: ❌ Not Ready' && echo '\\n=== Redis Status ===' && (docker exec twenty_redis redis-cli ping && echo 'Redis: ✅ Ready') || echo 'Redis: ❌ Not Ready' && sleep 30; done"
}
]
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ This directory contains Twenty's development guidelines and best practices in th
### React Development
- **react-general-guidelines.mdc** - Core React development principles (Auto-attached to React files)
- **react-state-management.mdc** - State management approaches with Jotai (Auto-attached to state files)
- **react-state-management.mdc** - State management approaches with Recoil (Auto-attached to state files)
### Testing & Quality
- **testing-guidelines.mdc** - Testing strategies and best practices (Auto-attached to test files)
+1 -1
View File
@@ -7,7 +7,7 @@ alwaysApply: true
# Twenty Architecture
## Tech Stack
- **Frontend**: React 18, TypeScript, Jotai, Styled Components, Vite
- **Frontend**: React 18, TypeScript, Recoil, Styled Components, Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL
- **Monorepo**: Nx workspace with yarn
File diff suppressed because it is too large Load Diff
+12 -33
View File
@@ -4,20 +4,16 @@ alwaysApply: false
---
# React State Management
## Jotai Patterns
## Recoil Patterns
```typescript
// ✅ Atoms for primitive state (use createAtomState for keyed state with optional persistence)
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const currentUserState = createAtomState<User | null>({
// ✅ Atoms for primitive state
export const currentUserState = atom<User | null>({
key: 'currentUserState',
defaultValue: null,
default: null,
});
// ✅ Derived atoms for computed state (use createAtomSelector)
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const userDisplayNameSelector = createAtomSelector({
// ✅ Selectors for derived state
export const userDisplayNameSelector = selector({
key: 'userDisplayNameSelector',
get: ({ get }) => {
const user = get(currentUserState);
@@ -25,30 +21,13 @@ export const userDisplayNameSelector = createAtomSelector({
},
});
// ✅ Atom factory pattern for dynamic atoms (use createAtomFamilyState)
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export const userByIdState = createAtomFamilyState<User | null, string>({
// ✅ Atom families for dynamic atoms
export const userByIdState = atomFamily<User | null, string>({
key: 'userByIdState',
defaultValue: null,
default: null,
});
```
## Jotai Hooks
```typescript
// useAtomState - read and write
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
// useAtomStateValue - read only
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
// useSetAtomState - write only
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
```
## Provider
Jotai works without a Provider by default. For scoped stores or testing, use `Provider` from `jotai`.
## Local State Guidelines
```typescript
// ✅ Multiple useState for unrelated state
@@ -95,7 +74,7 @@ const increment = useCallback(() => {
```
## Performance Tips
- Use atom factory pattern (createAtomFamilyState) for dynamic data collections
- Derived atoms (createAtomSelector) are automatically memoized by Jotai
- Avoid heavy computations in derived atoms
- Use atom families for dynamic data collections
- Implement proper selector caching
- Avoid heavy computations in selectors
- Batch state updates when possible
@@ -1,393 +0,0 @@
---
name: syncable-entity-builder-and-validation
description: Create validation logic and migration action builders for syncable entities in Twenty. Use when implementing business rule validation, uniqueness checks, foreign key validation, or building workspace migration actions for syncable entities. Validators never throw and never mutate.
---
# Syncable Entity: Builder & Validation (Step 3/6)
**Purpose**: Implement business rule validation and create migration action builders.
**When to use**: After completing Steps 1-2 (Types, Cache, Transform). Required before implementing action handlers.
---
## Quick Start
This step creates:
1. Validator service (business logic validation)
2. Builder service (action creation)
3. Orchestrator wiring (**CRITICAL** - often forgotten!)
**Key principles**:
- Validators **never throw** - return error arrays
- Validators **never mutate** - pass optimistic entity maps
- Use indexed lookups (O(1)) not `Object.values().find()` (O(n))
---
## Step 1: Create Validator Service
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { t, msg } from '@lingui/macro';
import { isDefined } from 'twenty-shared/utils';
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type';
import { WorkspaceMigrationValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/types/workspace-migration-validation-error.type';
import { MyEntityExceptionCode } from 'src/engine/metadata-modules/my-entity/exceptions/my-entity-exception-code.enum';
@Injectable()
export class FlatMyEntityValidatorService {
validateMyEntityForCreate(
flatMyEntity: FlatMyEntity,
optimisticFlatMyEntityMaps: FlatMyEntityMaps,
): WorkspaceMigrationValidationError[] {
const errors: WorkspaceMigrationValidationError[] = [];
// Pattern 1: Required field validation
if (!isDefined(flatMyEntity.name) || flatMyEntity.name.trim() === '') {
errors.push({
code: MyEntityExceptionCode.NAME_REQUIRED,
message: t`Name is required`,
userFriendlyMessage: msg`Please provide a name for this entity`,
});
}
// Pattern 2: Uniqueness check - use indexed map (O(1))
const existingEntityWithName = optimisticFlatMyEntityMaps.byName[flatMyEntity.name];
if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) {
errors.push({
code: MyEntityExceptionCode.MY_ENTITY_ALREADY_EXISTS,
message: t`Entity with name ${flatMyEntity.name} already exists`,
userFriendlyMessage: msg`An entity with this name already exists`,
});
}
// Pattern 3: Foreign key validation
if (isDefined(flatMyEntity.parentEntityId)) {
const parentEntity = optimisticFlatParentEntityMaps.byId[flatMyEntity.parentEntityId];
if (!isDefined(parentEntity)) {
errors.push({
code: MyEntityExceptionCode.PARENT_ENTITY_NOT_FOUND,
message: t`Parent entity with ID ${flatMyEntity.parentEntityId} not found`,
userFriendlyMessage: msg`The specified parent entity does not exist`,
});
} else if (isDefined(parentEntity.deletedAt)) {
errors.push({
code: MyEntityExceptionCode.PARENT_ENTITY_DELETED,
message: t`Parent entity is deleted`,
userFriendlyMessage: msg`Cannot reference a deleted parent entity`,
});
}
}
// Pattern 4: Standard entity protection
if (flatMyEntity.isCustom === false) {
errors.push({
code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_CREATED,
message: t`Cannot create standard entity`,
userFriendlyMessage: msg`Standard entities can only be created by the system`,
});
}
return errors;
}
validateMyEntityForUpdate(
flatMyEntity: FlatMyEntity,
updates: Partial<FlatMyEntity>,
optimisticFlatMyEntityMaps: FlatMyEntityMaps,
): WorkspaceMigrationValidationError[] {
const errors: WorkspaceMigrationValidationError[] = [];
// Standard entity protection
if (flatMyEntity.isCustom === false) {
errors.push({
code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_UPDATED,
message: t`Cannot update standard entity`,
userFriendlyMessage: msg`Standard entities cannot be modified`,
});
return errors; // Early return if standard
}
// Uniqueness check for name changes
if (isDefined(updates.name) && updates.name !== flatMyEntity.name) {
const existingEntityWithName = optimisticFlatMyEntityMaps.byName[updates.name];
if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) {
errors.push({
code: MyEntityExceptionCode.MY_ENTITY_ALREADY_EXISTS,
message: t`Entity with name ${updates.name} already exists`,
userFriendlyMessage: msg`An entity with this name already exists`,
});
}
}
return errors;
}
validateMyEntityForDelete(
flatMyEntity: FlatMyEntity,
): WorkspaceMigrationValidationError[] {
const errors: WorkspaceMigrationValidationError[] = [];
// Standard entity protection
if (flatMyEntity.isCustom === false) {
errors.push({
code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_DELETED,
message: t`Cannot delete standard entity`,
userFriendlyMessage: msg`Standard entities cannot be deleted`,
});
}
return errors;
}
}
```
**Performance warning**: Avoid `Object.values().find()` - use indexed maps instead!
```typescript
// ❌ BAD: O(n) - slow for large datasets
const duplicate = Object.values(optimisticFlatMyEntityMaps.byId).find(
(entity) => entity.name === flatMyEntity.name && entity.id !== flatMyEntity.id
);
// ✅ GOOD: O(1) - use indexed map
const existingEntityWithName = optimisticFlatMyEntityMaps.byName[flatMyEntity.name];
if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) {
// Handle duplicate
}
```
---
## Step 2: Create Builder Service
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-entity-migration-builder.service';
import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service';
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
import {
type UniversalCreateMyEntityAction,
type UniversalUpdateMyEntityAction,
type UniversalDeleteMyEntityAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
@Injectable()
export class WorkspaceMigrationMyEntityActionsBuilderService extends WorkspaceEntityMigrationBuilderService<
'myEntity',
UniversalFlatMyEntity,
UniversalCreateMyEntityAction,
UniversalUpdateMyEntityAction,
UniversalDeleteMyEntityAction
> {
constructor(
private readonly flatMyEntityValidatorService: FlatMyEntityValidatorService,
) {
super();
}
protected buildCreateAction(
universalFlatMyEntity: UniversalFlatMyEntity,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): BuildWorkspaceMigrationActionReturnType<UniversalCreateMyEntityAction> {
const validationResult = this.flatMyEntityValidatorService.validateMyEntityForCreate(
universalFlatMyEntity,
flatEntityMaps.flatMyEntityMaps,
);
if (validationResult.length > 0) {
return {
status: 'failed',
errors: validationResult,
};
}
return {
status: 'success',
action: {
type: 'create',
metadataName: 'myEntity',
universalFlatEntity: universalFlatMyEntity,
},
};
}
protected buildUpdateAction(
universalFlatMyEntity: UniversalFlatMyEntity,
universalUpdates: Partial<UniversalFlatMyEntity>,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): BuildWorkspaceMigrationActionReturnType<UniversalUpdateMyEntityAction> {
const validationResult = this.flatMyEntityValidatorService.validateMyEntityForUpdate(
universalFlatMyEntity,
universalUpdates,
flatEntityMaps.flatMyEntityMaps,
);
if (validationResult.length > 0) {
return {
status: 'failed',
errors: validationResult,
};
}
return {
status: 'success',
action: {
type: 'update',
metadataName: 'myEntity',
universalFlatEntity: universalFlatMyEntity,
universalUpdates,
},
};
}
protected buildDeleteAction(
universalFlatMyEntity: UniversalFlatMyEntity,
): BuildWorkspaceMigrationActionReturnType<UniversalDeleteMyEntityAction> {
const validationResult = this.flatMyEntityValidatorService.validateMyEntityForDelete(
universalFlatMyEntity,
);
if (validationResult.length > 0) {
return {
status: 'failed',
errors: validationResult,
};
}
return {
status: 'success',
action: {
type: 'delete',
metadataName: 'myEntity',
universalFlatEntity: universalFlatMyEntity,
},
};
}
}
```
---
## Step 3: Wire into Orchestrator (**CRITICAL**)
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-build-orchestrator.service.ts`
```typescript
@Injectable()
export class WorkspaceMigrationBuildOrchestratorService {
constructor(
// ... existing builders
private readonly workspaceMigrationMyEntityActionsBuilderService: WorkspaceMigrationMyEntityActionsBuilderService,
) {}
async buildWorkspaceMigration({
allFlatEntityOperationByMetadataName,
flatEntityMaps,
isSystemBuild,
}: BuildWorkspaceMigrationInput): Promise<BuildWorkspaceMigrationOutput> {
// ... existing code
// Add your entity builder
const myEntityResult = await this.workspaceMigrationMyEntityActionsBuilderService.build({
flatEntitiesToCreate: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToCreate ?? [],
flatEntitiesToUpdate: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToUpdate ?? [],
flatEntitiesToDelete: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToDelete ?? [],
flatEntityMaps,
isSystemBuild,
});
// ... aggregate errors
return {
status: aggregatedErrors.length > 0 ? 'failed' : 'success',
errors: aggregatedErrors,
actions: [
...existingActions,
...myEntityResult.actions,
],
};
}
}
```
**⚠️ This step is the most commonly forgotten!** Your entity won't sync without orchestrator wiring.
---
## Validation Patterns
### Pattern 1: Required Field
```typescript
if (!isDefined(field) || field.trim() === '') {
errors.push({ code: ..., message: ..., userFriendlyMessage: ... });
}
```
### Pattern 2: Uniqueness (O(1) lookup)
```typescript
const existing = optimisticMaps.byName[entity.name];
if (isDefined(existing) && existing.id !== entity.id) {
errors.push({ ... });
}
```
### Pattern 3: Foreign Key Validation
```typescript
if (isDefined(entity.parentId)) {
const parent = parentMaps.byId[entity.parentId];
if (!isDefined(parent)) {
errors.push({ code: NOT_FOUND, ... });
} else if (isDefined(parent.deletedAt)) {
errors.push({ code: DELETED, ... });
}
}
```
### Pattern 4: Standard Entity Protection
```typescript
if (entity.isCustom === false) {
errors.push({ code: STANDARD_ENTITY_PROTECTED, ... });
return errors; // Early return
}
```
---
## Checklist
Before moving to Step 4:
- [ ] Validator service created
- [ ] Validator **never throws** (returns error arrays)
- [ ] Validator **never mutates** (uses optimistic maps)
- [ ] All uniqueness checks use indexed maps (O(1))
- [ ] Required field validation implemented
- [ ] Foreign key validation implemented
- [ ] Standard entity protection implemented
- [ ] Builder service extends `WorkspaceEntityMigrationBuilderService`
- [ ] Builder creates actions with universal entities
- [ ] **Builder wired into orchestrator** (**CRITICAL**)
- [ ] **Builder injected in orchestrator constructor**
- [ ] **Builder called in `buildWorkspaceMigration`**
- [ ] **Actions added to orchestrator return statement**
---
## Next Step
Once builder and validation are complete, proceed to:
**[Syncable Entity: Runner & Actions (Step 4/6)](../syncable-entity-runner-and-actions/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,303 +0,0 @@
---
name: syncable-entity-cache-and-transform
description: Create cache services and transformation utilities for syncable entities in Twenty. Use when implementing entity-to-flat conversions, input DTO transpilation to universal flat entities, or cache recomputation for syncable entities.
---
# Syncable Entity: Cache & Transform (Step 2/6)
**Purpose**: Create cache layer and transformation utilities to convert between different entity representations.
**When to use**: After completing Step 1 (Types & Constants). Required before building validators and action handlers.
---
## Quick Start
This step creates:
1. Cache service for flat entity maps
2. Entity-to-flat conversion utility
3. Input transform utils (DTO → Universal Flat Entity)
**Key principle**: Input transform utils must output **universal flat entities** (with `universalIdentifier` and foreign keys mapped to universal identifiers).
---
## Step 1: Create Cache Service
**File**: `src/engine/metadata-modules/flat-my-entity/services/flat-my-entity-cache.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { WorkspaceCache } from 'src/engine/twenty-orm/decorators/workspace-cache.decorator';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type';
import { fromMyEntityEntityToFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util';
@Injectable()
export class FlatMyEntityCacheService {
constructor(
@InjectRepository(MyEntityEntity, 'metadata')
private readonly myEntityRepository: Repository<MyEntityEntity>,
) {}
@WorkspaceCache({ flatMapsKey: 'flatMyEntityMaps' })
async getFlatMyEntityMaps(): Promise<FlatMyEntityMaps> {
const myEntities = await this.myEntityRepository.find({
withDeleted: true, // CRITICAL: Include soft-deleted entities
});
const flatMyEntities = myEntities.map((entity) =>
fromMyEntityEntityToFlatMyEntity(entity),
);
return {
byId: Object.fromEntries(flatMyEntities.map((e) => [e.id, e])),
byName: Object.fromEntries(flatMyEntities.map((e) => [e.name, e])),
};
}
}
```
**Critical rules**:
- Use `@WorkspaceCache` decorator with unique `flatMapsKey`
- **Always** use `withDeleted: true` to include soft-deleted entities
- Cache key pattern: `flat{EntityName}Maps` (camelCase)
---
## Step 2: Entity-to-Flat Conversion
**File**: `src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util.ts`
```typescript
import { v4 } from 'uuid';
import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
export const fromMyEntityEntityToFlatMyEntity = (
entity: MyEntityEntity,
): FlatMyEntity => {
return {
id: entity.id,
// Critical: generate a new UUID for universalIdentifier
universalIdentifier: v4(),
workspaceId: entity.workspaceId,
applicationId: entity.applicationId,
name: entity.name,
label: entity.label,
description: entity.description,
isCustom: entity.isCustom,
parentEntityId: entity.parentEntityId,
settings: entity.settings,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
deletedAt: entity.deletedAt?.toISOString() ?? null,
};
};
```
**Critical**: `universalIdentifier` must be a new UUID generated with `v4()` (not `entity.id`)
---
## Step 3: Input Transform Utils (DTO → Universal Flat Entity)
**File**: `src/engine/metadata-modules/flat-my-entity/utils/from-create-my-entity-input-to-universal-flat-my-entity.util.ts`
```typescript
import { v4 } from 'uuid';
import { sanitizeString } from 'twenty-shared/string';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { type AllFlatEntityMapsByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps-by-metadata-name.type';
export const fromCreateMyEntityInputToUniversalFlatMyEntity = ({
input,
workspaceId,
flatEntityMaps,
}: {
input: CreateMyEntityInput;
workspaceId: string;
flatEntityMaps?: AllFlatEntityMapsByMetadataName;
}): UniversalFlatMyEntity => {
const id = v4();
const universalIdentifier = v4();
// 1. Extract foreign key IDs BEFORE sanitization
const parentEntityId = input.parentEntityId ?? null;
// 2. Sanitize string properties
const name = sanitizeString(input.name);
const label = sanitizeString(input.label);
const description = input.description ? sanitizeString(input.description) : null;
// 3. Build base flat entity
const baseFlatEntity = {
id,
universalIdentifier,
workspaceId,
applicationId: null,
name,
label,
description,
isCustom: true,
parentEntityId,
settings: input.settings ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
// 4. Resolve foreign keys to universal identifiers (if flatEntityMaps provided)
if (flatEntityMaps) {
return resolveEntityRelationUniversalIdentifiers({
metadataName: 'myEntity',
flatEntity: baseFlatEntity,
flatEntityMaps,
});
}
// 5. Return with null universal foreign keys if no maps
return {
...baseFlatEntity,
parentEntityUniversalIdentifier: null,
};
};
```
**Key steps**:
1. Generate IDs (`id` and `universalIdentifier` with `v4()`)
2. Extract foreign keys **before** sanitization
3. Sanitize all string properties
4. Build base flat entity
5. Resolve foreign keys → universal identifiers
---
## Step 4: Create Flat Entity Module
**File**: `src/engine/metadata-modules/flat-my-entity/flat-my-entity.module.ts`
```typescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { FlatMyEntityCacheService } from 'src/engine/metadata-modules/flat-my-entity/services/flat-my-entity-cache.service';
@Module({
imports: [TypeOrmModule.forFeature([MyEntityEntity], 'metadata')],
providers: [FlatMyEntityCacheService],
exports: [FlatMyEntityCacheService],
})
export class FlatMyEntityModule {}
```
**Rules**:
- Import entity with `'metadata'` datasource
- Export cache service for use in other modules
---
## Common Patterns
### Pattern: Foreign Key Resolution
```typescript
// Extract foreign keys BEFORE sanitization
const parentEntityId = input.parentEntityId ?? null;
// After building base entity, resolve to universal identifiers
const universalFlatEntity = resolveEntityRelationUniversalIdentifiers({
metadataName: 'myEntity',
flatEntity: baseFlatEntity,
flatEntityMaps,
});
```
### Pattern: JSONB with SerializedRelation
```typescript
// For JSONB properties containing foreign keys
const settings = input.settings
? {
...input.settings,
fieldMetadataId: input.settings.fieldMetadataId,
}
: null;
// After resolution, JSONB foreign keys become universal identifiers
return resolveEntityRelationUniversalIdentifiers({
metadataName: 'myEntity',
flatEntity: { ...baseFlatEntity, settings },
flatEntityMaps,
});
```
### Pattern: Update Transform
```typescript
// from-update-my-entity-input-to-universal-flat-my-entity-updates.util.ts
export const fromUpdateMyEntityInputToUniversalFlatMyEntityUpdates = ({
input,
flatEntityMaps,
}: {
input: UpdateMyEntityInput;
flatEntityMaps?: AllFlatEntityMapsByMetadataName;
}): Partial<UniversalFlatMyEntity> => {
const updates: Partial<UniversalFlatMyEntity> = {};
if (input.name !== undefined) {
updates.name = sanitizeString(input.name);
}
if (input.parentEntityId !== undefined) {
updates.parentEntityId = input.parentEntityId;
}
updates.updatedAt = new Date().toISOString();
// Resolve foreign keys if maps provided
if (flatEntityMaps) {
return resolveEntityRelationUniversalIdentifiers({
metadataName: 'myEntity',
flatEntity: updates as any,
flatEntityMaps,
});
}
return updates;
};
```
---
## Checklist
Before moving to Step 3:
- [ ] Cache service created with `@WorkspaceCache` decorator
- [ ] Cache uses `withDeleted: true`
- [ ] Cache key follows `flat{EntityName}Maps` pattern
- [ ] Entity-to-flat conversion implemented
- [ ] `universalIdentifier` set correctly (generated with `v4()`)
- [ ] Create input transform implemented
- [ ] Update input transform implemented (if needed)
- [ ] Foreign keys extracted before sanitization
- [ ] String properties sanitized
- [ ] Foreign keys resolved to universal identifiers
- [ ] Flat entity module created and exports cache service
---
## Next Step
Once cache and transform utilities are complete, proceed to:
**[Syncable Entity: Builder & Validation (Step 3/6)](../syncable-entity-builder-and-validation/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,326 +0,0 @@
---
name: syncable-entity-integration
description: Wire syncable entity services into NestJS modules, create service layer and resolvers for Twenty entities. Use when registering builders, validators, and action handlers in modules, creating business services, or exposing entities via GraphQL API with proper exception handling.
---
# Syncable Entity: Integration (Step 5/6)
**Purpose**: Wire everything together, register in modules, create services and resolvers.
**When to use**: After completing Steps 1-4 (all previous steps). Required before testing.
---
## Quick Start
This step:
1. Registers services in 3 NestJS modules
2. Creates service layer (returns flat entities)
3. Creates resolver layer (converts flat → DTO)
4. Uses exception interceptor for GraphQL
**Key principle**: Services return flat entities, resolvers transpile flat → DTO.
---
## Step 1: Register in Builder Module
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts`
```typescript
import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service';
@Module({
imports: [
// ... existing imports
],
providers: [
// ... existing providers
WorkspaceMigrationMyEntityActionsBuilderService,
],
exports: [
// ... existing exports
WorkspaceMigrationMyEntityActionsBuilderService,
],
})
export class WorkspaceMigrationBuilderModule {}
```
**Important**: Add to both `providers` AND `exports` (builder needs to be exported for orchestrator).
---
## Step 2: Register in Validators Module
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts`
```typescript
import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service';
@Module({
imports: [
// ... existing imports
],
providers: [
// ... existing providers
FlatMyEntityValidatorService,
],
exports: [
// ... existing exports
FlatMyEntityValidatorService,
],
})
export class WorkspaceMigrationBuilderValidatorsModule {}
```
---
## Step 3: Register Action Handlers
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts`
```typescript
import { CreateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service';
import { UpdateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service';
import { DeleteMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service';
@Module({
imports: [
// ... existing imports
],
providers: [
// ... existing providers
CreateMyEntityActionHandlerService,
UpdateMyEntityActionHandlerService,
DeleteMyEntityActionHandlerService,
],
exports: [
// ... existing exports (action handlers typically not exported)
],
})
export class WorkspaceSchemaMigrationRunnerActionHandlersModule {}
```
**Note**: Action handlers are typically only in `providers`, not `exports`.
---
## Step 4: Create Service Layer
**File**: `src/engine/metadata-modules/my-entity/my-entity.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateMyEntityInputToUniversalFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-create-my-entity-input-to-universal-flat-my-entity.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class MyEntityService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async create(input: CreateMyEntityInput, workspaceId: string): Promise<FlatMyEntity> {
// 1. Transform input to universal flat entity
const universalFlatMyEntityToCreate = fromCreateMyEntityInputToUniversalFlatMyEntity({
input,
workspaceId,
});
// 2. Validate, build, and run
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
myEntity: {
flatEntityToCreate: [universalFlatMyEntityToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
// 3. Throw if validation failed
if (isDefined(result)) {
throw new WorkspaceMigrationBuilderException(
result,
'Validation errors occurred while creating entity',
);
}
// 4. Return freshly cached flat entity
const { flatMyEntityMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatMyEntityMaps'],
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: universalFlatMyEntityToCreate.id,
flatEntityMaps: flatMyEntityMaps,
});
}
}
```
**Service pattern**:
1. Transform input → universal flat entity
2. Call `validateBuildAndRunWorkspaceMigration`
3. Throw if validation errors
4. **Return flat entity** (not DTO)
---
## Step 5: Create Resolver Layer
**File**: `src/engine/metadata-modules/my-entity/my-entity.resolver.ts`
```typescript
import { UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { MyEntityService } from 'src/engine/metadata-modules/my-entity/my-entity.service';
import { fromFlatMyEntityToMyEntityDto } from 'src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util';
@Resolver(() => MyEntityDto)
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
export class MyEntityResolver {
constructor(private readonly myEntityService: MyEntityService) {}
@Mutation(() => MyEntityDto)
async createMyEntity(
@Args('input') input: CreateMyEntityInput,
@Workspace() { id: workspaceId }: Workspace,
): Promise<MyEntityDto> {
// Service returns flat entity
const flatMyEntity = await this.myEntityService.create(input, workspaceId);
// Resolver converts flat entity to DTO
return fromFlatMyEntityToMyEntityDto(flatMyEntity);
}
@Mutation(() => MyEntityDto)
async updateMyEntity(
@Args('id') id: string,
@Args('input') input: UpdateMyEntityInput,
@Workspace() { id: workspaceId }: Workspace,
): Promise<MyEntityDto> {
const flatMyEntity = await this.myEntityService.update(id, input, workspaceId);
return fromFlatMyEntityToMyEntityDto(flatMyEntity);
}
@Mutation(() => Boolean)
async deleteMyEntity(
@Args('id') id: string,
@Workspace() { id: workspaceId }: Workspace,
) {
await this.myEntityService.delete(id, workspaceId);
return true;
}
}
```
**Resolver responsibilities**:
- Receives flat entities from service
- **Converts flat → DTO** using conversion utility
- Returns DTOs to GraphQL API
- Uses exception interceptor for error formatting
---
## Step 6: Flat-to-DTO Conversion
**File**: `src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util.ts`
```typescript
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto';
export const fromFlatMyEntityToMyEntityDto = (
flatMyEntity: FlatMyEntity,
): MyEntityDto => {
return {
id: flatMyEntity.id,
name: flatMyEntity.name,
label: flatMyEntity.label,
description: flatMyEntity.description,
isCustom: flatMyEntity.isCustom,
createdAt: flatMyEntity.createdAt,
updatedAt: flatMyEntity.updatedAt,
// Convert foreign key IDs to relation objects if needed
// parentEntity: flatMyEntity.parentEntityId ? { id: flatMyEntity.parentEntityId } : null,
};
};
```
---
## Layer Responsibilities
| Layer | Input | Output | Responsibility |
|-------|-------|--------|----------------|
| **Service** | Input DTO | Flat Entity | Business logic, validation orchestration |
| **Resolver** | Service result | DTO | Flat → DTO conversion, GraphQL exposure |
**Service Layer**:
- Works with flat entities internally
- Returns `FlatMyEntity` type
- No knowledge of DTOs or GraphQL types
**Resolver Layer**:
- Receives flat entities from service
- Converts flat entities to DTOs
- Returns DTOs to GraphQL API
---
## Exception Interceptor
The `WorkspaceMigrationGraphqlApiExceptionInterceptor` automatically handles:
1. `FlatEntityMapsException` → Converts to GraphQL errors (NotFoundError, etc.)
2. `WorkspaceMigrationBuilderException` → Formats validation errors with i18n
3. `WorkspaceMigrationRunnerException` → Formats runner errors
**What it does**:
- Catches exceptions and formats for API responses
- Translates error messages based on user locale
- Ensures consistent error structure for frontend
---
## Checklist
Before moving to Step 6 (Testing):
- [ ] Builder registered in builder module (providers + exports)
- [ ] Validator registered in validators module (providers + exports)
- [ ] All 3 action handlers registered in action handlers module (providers)
- [ ] Service layer created
- [ ] Service returns flat entities (not DTOs)
- [ ] Resolver layer created
- [ ] Resolver uses exception interceptor
- [ ] Resolver converts flat → DTO
- [ ] Flat-to-DTO conversion utility created
---
## Next Step
Once integration is complete, proceed to (**MANDATORY**):
**[Syncable Entity: Integration Testing (Step 6/6)](../syncable-entity-testing/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,355 +0,0 @@
---
name: syncable-entity-runner-and-actions
description: Implement action handlers for executing workspace migrations in Twenty. Use when creating database operations for syncable entities, implementing universal-to-flat entity transpilation, or handling create/update/delete actions in the runner layer.
---
# Syncable Entity: Runner & Actions (Step 4/6)
**Purpose**: Execute migration actions against the database with proper transpilation from universal to flat entities.
**When to use**: After completing Steps 1-3 (Types, Cache, Builder). Required before integration.
---
## Quick Start
This step creates:
1. Create action handler
2. Update action handler
3. Delete action handler
4. Universal-to-flat conversion utilities
**Key pattern**: Each handler has two phases:
1. **Transpilation**: Universal action → Flat action
2. **Execution**: Flat action → Database operation
---
## Step 1: Create Action Handler
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCreateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-create-action-handler.service';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
import {
type UniversalCreateMyEntityAction,
type FlatCreateMyEntityAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
@Injectable()
export class CreateMyEntityActionHandlerService extends WorkspaceCreateActionHandlerService<
'myEntity',
UniversalCreateMyEntityAction,
FlatCreateMyEntityAction
> {
constructor(
@InjectRepository(MyEntityEntity, 'metadata')
private readonly myEntityRepository: Repository<MyEntityEntity>,
) {
super();
}
// Phase 1: Transpile universal action to flat action
protected transpileUniversalActionToFlatAction(
universalAction: UniversalCreateMyEntityAction,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): FlatCreateMyEntityAction {
return {
type: 'create',
metadataName: 'myEntity',
flatEntity: fromUniversalFlatMyEntityToFlatMyEntity(
universalAction.universalFlatEntity,
flatEntityMaps,
),
};
}
// Phase 2: Execute flat action against database
protected async executeForMetadata(
flatActions: FlatCreateMyEntityAction[],
): Promise<void> {
const flatEntities = flatActions.map((action) => action.flatEntity);
await this.insertFlatEntitiesInRepository({
repository: this.myEntityRepository,
flatEntities,
});
}
protected async executeForWorkspaceSchema(): Promise<void> {
// No workspace schema changes needed for metadata-only entity
return;
}
}
```
**Key helper methods**:
- `transpileUniversalActionToFlatAction`: Converts universal → flat
- `insertFlatEntitiesInRepository`: Base class helper for inserts
- `executeForMetadata`: Metadata database operations
- `executeForWorkspaceSchema`: Workspace schema changes (if needed)
---
## Step 2: Update Action Handler
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceUpdateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-update-action-handler.service';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util';
@Injectable()
export class UpdateMyEntityActionHandlerService extends WorkspaceUpdateActionHandlerService<
'myEntity',
UniversalUpdateMyEntityAction,
FlatUpdateMyEntityAction
> {
constructor(
@InjectRepository(MyEntityEntity, 'metadata')
private readonly myEntityRepository: Repository<MyEntityEntity>,
) {
super();
}
protected transpileUniversalActionToFlatAction(
universalAction: UniversalUpdateMyEntityAction,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): FlatUpdateMyEntityAction {
const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity(
universalAction.universalFlatEntity,
flatEntityMaps,
);
// Resolve universal foreign keys in updates to regular IDs
const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'myEntity',
universalUpdates: universalAction.universalUpdates,
flatEntityMaps,
});
return {
type: 'update',
metadataName: 'myEntity',
flatEntity,
updates: flatUpdates,
};
}
protected async executeForMetadata(
flatActions: FlatUpdateMyEntityAction[],
): Promise<void> {
for (const action of flatActions) {
await this.myEntityRepository.update(
{ id: action.flatEntity.id },
action.updates,
);
}
}
protected async executeForWorkspaceSchema(): Promise<void> {
return;
}
}
```
**Update-specific helper**:
- `resolveUniversalUpdateRelationIdentifiersToIds`: Maps universal identifiers back to regular IDs in the updates object
---
## Step 3: Delete Action Handler
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceDeleteActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-delete-action-handler.service';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
@Injectable()
export class DeleteMyEntityActionHandlerService extends WorkspaceDeleteActionHandlerService<
'myEntity',
UniversalDeleteMyEntityAction,
FlatDeleteMyEntityAction
> {
constructor(
@InjectRepository(MyEntityEntity, 'metadata')
private readonly myEntityRepository: Repository<MyEntityEntity>,
) {
super();
}
protected transpileUniversalActionToFlatAction(
universalAction: UniversalDeleteMyEntityAction,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): FlatDeleteMyEntityAction {
// Use base class helper for delete transpilation
return this.transpileUniversalDeleteActionToFlatDeleteAction({
universalAction,
flatEntityMaps,
fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity,
});
}
protected async executeForMetadata(
flatActions: FlatDeleteMyEntityAction[],
): Promise<void> {
const ids = flatActions.map((action) => action.flatEntity.id);
await this.myEntityRepository.delete(ids);
}
protected async executeForWorkspaceSchema(): Promise<void> {
return;
}
}
```
**Delete-specific helper**:
- `transpileUniversalDeleteActionToFlatDeleteAction`: Base class helper that handles standard delete transpilation
---
## Step 4: Universal-to-Flat Conversion
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util.ts`
```typescript
import { resolveUniversalRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util';
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { type AllFlatEntityMapsByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps-by-metadata-name.type';
export const fromUniversalFlatMyEntityToFlatMyEntity = (
universalFlatMyEntity: UniversalFlatMyEntity,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): FlatMyEntity => {
// Resolve universal foreign keys back to regular IDs
return resolveUniversalRelationIdentifiersToIds({
metadataName: 'myEntity',
universalFlatEntity: universalFlatMyEntity,
flatEntityMaps,
}) as FlatMyEntity;
};
```
**Key utility**:
- `resolveUniversalRelationIdentifiersToIds`: Maps universal identifiers → regular IDs (reverse of `resolveEntityRelationUniversalIdentifiers`)
---
## Action Handler Patterns
### Pattern: Create Handler
```typescript
// 1. Transpile: Universal → Flat
protected transpileUniversalActionToFlatAction(
universalAction,
flatEntityMaps,
) {
return {
type: 'create',
metadataName: 'myEntity',
flatEntity: fromUniversalFlatMyEntityToFlatMyEntity(
universalAction.universalFlatEntity,
flatEntityMaps,
),
};
}
// 2. Execute: Flat → Database
protected async executeForMetadata(flatActions) {
await this.insertFlatEntitiesInRepository({
repository: this.myEntityRepository,
flatEntities: flatActions.map(a => a.flatEntity),
});
}
```
### Pattern: Update Handler
```typescript
// Transpile with update-specific resolution
protected transpileUniversalActionToFlatAction(
universalAction,
flatEntityMaps,
) {
const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity(
universalAction.universalFlatEntity,
flatEntityMaps,
);
const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'myEntity',
universalUpdates: universalAction.universalUpdates,
flatEntityMaps,
});
return { type: 'update', metadataName: 'myEntity', flatEntity, updates: flatUpdates };
}
```
### Pattern: Delete Handler
```typescript
// Use base class helper
protected transpileUniversalActionToFlatAction(
universalAction,
flatEntityMaps,
) {
return this.transpileUniversalDeleteActionToFlatDeleteAction({
universalAction,
flatEntityMaps,
fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity,
});
}
// Delete
protected async executeForMetadata(flatActions) {
const ids = flatActions.map(a => a.flatEntity.id);
await this.myEntityRepository.delete(ids);
}
```
---
## Checklist
Before moving to Step 5:
- [ ] Create action handler implemented
- [ ] Update action handler implemented
- [ ] Delete action handler implemented
- [ ] All handlers extend appropriate base class
- [ ] `transpileUniversalActionToFlatAction` implemented in all handlers
- [ ] `executeForMetadata` implemented in all handlers
- [ ] `executeForWorkspaceSchema` implemented (or returns empty)
- [ ] Universal-to-flat conversion utility created
- [ ] Create handler uses `insertFlatEntitiesInRepository`
- [ ] Update handler uses `resolveUniversalUpdateRelationIdentifiersToIds`
- [ ] Delete handler uses `transpileUniversalDeleteActionToFlatDeleteAction`
- [ ] Delete handler uses hard delete (`delete()`)
---
## Next Step
Once action handlers are complete, proceed to:
**[Syncable Entity: Integration (Step 5/6)](../syncable-entity-integration/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,494 +0,0 @@
---
name: syncable-entity-testing
description: Create comprehensive integration tests for syncable entities in Twenty. Use when writing integration tests for metadata entities, covering validator exceptions, input transpilation errors, and CRUD operations. Tests are MANDATORY for all syncable entities.
---
# Syncable Entity: Integration Testing (Step 6/6 - MANDATORY)
**Purpose**: Create comprehensive test suite covering all validation scenarios, input transpilation exceptions, and successful use cases.
**When to use**: After completing Steps 1-5. Integration tests are **REQUIRED** for all syncable entities.
---
## Quick Start
Tests must cover:
1. **Failing scenarios** - All validator exceptions and input transpilation errors
2. **Successful scenarios** - All CRUD operations and edge cases
3. **Test utilities** - Reusable query factories and helper functions
**Test pattern**: Two-file pattern (query factory + wrapper) for each operation.
---
## Step 1: Create Test Utilities
### Pattern: Query Factory
**File**: `test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util.ts`
```typescript
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
export type CreateMyEntityFactoryInput = CreateMyEntityInput;
const DEFAULT_MY_ENTITY_GQL_FIELDS = `
id
name
label
description
isCustom
createdAt
updatedAt
`;
export const createMyEntityQueryFactory = ({
input,
gqlFields = DEFAULT_MY_ENTITY_GQL_FIELDS,
}: PerformMetadataQueryParams<CreateMyEntityFactoryInput>) => ({
query: gql`
mutation CreateMyEntity($input: CreateMyEntityInput!) {
createMyEntity(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});
```
### Pattern: Wrapper Utility
**File**: `test/integration/metadata/suites/my-entity/utils/create-my-entity.util.ts`
```typescript
import {
type CreateMyEntityFactoryInput,
createMyEntityQueryFactory,
} from 'test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto';
export const createMyEntity = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateMyEntityFactoryInput>): CommonResponseBody<{
createMyEntity: MyEntityDto;
}> => {
const graphqlOperation = createMyEntityQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'My entity creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'My entity creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
```
**Required utilities** (follow same pattern):
- `update-my-entity-query-factory.util.ts` + `update-my-entity.util.ts`
- `delete-my-entity-query-factory.util.ts` + `delete-my-entity.util.ts`
---
## Step 2: Failing Creation Tests
**File**: `test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts`
```typescript
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util';
import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
type TestContext = {
input: CreateMyEntityInput;
};
type GlobalTestContext = {
existingEntityLabel: string;
existingEntityName: string;
};
const globalTestContext: GlobalTestContext = {
existingEntityLabel: 'Existing Test Entity',
existingEntityName: 'existingTestEntity',
};
type CreateMyEntityTestingContext = EachTestingContext<TestContext>[];
describe('My entity creation should fail', () => {
let existingEntityId: string | undefined;
beforeAll(async () => {
// Setup: Create entity for uniqueness tests
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: globalTestContext.existingEntityName,
label: globalTestContext.existingEntityLabel,
},
});
existingEntityId = data.createMyEntity.id;
});
afterAll(async () => {
// Cleanup
if (isDefined(existingEntityId)) {
await deleteMyEntity({
expectToFail: false,
input: { id: existingEntityId },
});
}
});
const failingMyEntityCreationTestCases: CreateMyEntityTestingContext = [
// Input transpilation validation
{
title: 'when name is missing',
context: {
input: {
label: 'Entity Missing Name',
} as CreateMyEntityInput,
},
},
{
title: 'when label is missing',
context: {
input: {
name: 'entityMissingLabel',
} as CreateMyEntityInput,
},
},
{
title: 'when name is empty string',
context: {
input: {
name: '',
label: 'Empty Name Entity',
},
},
},
// Validator business logic
{
title: 'when name already exists (uniqueness)',
context: {
input: {
name: globalTestContext.existingEntityName,
label: 'Duplicate Name Entity',
},
},
},
{
title: 'when trying to create standard entity',
context: {
input: {
name: 'myEntity',
label: 'Standard Entity',
isCustom: false,
} as CreateMyEntityInput,
},
},
// Foreign key validation
{
title: 'when parentEntityId does not exist',
context: {
input: {
name: 'invalidParentEntity',
label: 'Invalid Parent Entity',
parentEntityId: '00000000-0000-0000-0000-000000000000',
},
},
},
];
it.each(eachTestingContextFilter(failingMyEntityCreationTestCases))(
'$title',
async ({ context }) => {
const { errors } = await createMyEntity({
expectToFail: true,
input: context.input,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
});
```
**Test coverage requirements**:
- ✅ Missing required fields
- ✅ Empty strings
- ✅ Invalid format
- ✅ Uniqueness violations
- ✅ Standard entity protection
- ✅ Foreign key validation
---
## Step 3: Successful Creation Tests
**File**: `test/integration/metadata/suites/my-entity/successful-my-entity-creation.integration-spec.ts`
```typescript
import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util';
import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
describe('My entity creation should succeed', () => {
let createdEntityId: string;
afterEach(async () => {
if (createdEntityId) {
await deleteMyEntity({
expectToFail: false,
input: { id: createdEntityId },
});
}
});
it('should create entity with minimal required input', async () => {
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: 'minimalEntity',
label: 'Minimal Entity',
},
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
name: 'minimalEntity',
label: 'Minimal Entity',
description: null,
isCustom: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
it('should create entity with all optional fields', async () => {
const input = {
name: 'fullEntity',
label: 'Full Entity',
description: 'Entity with all fields specified',
} as const satisfies CreateMyEntityInput;
const { data } = await createMyEntity({
expectToFail: false,
input,
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
name: 'fullEntity',
label: 'Full Entity',
description: 'Entity with all fields specified',
isCustom: true,
});
});
it('should sanitize input by trimming whitespace', async () => {
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: ' entityWithSpaces ',
label: ' Entity With Spaces ',
description: ' Description with spaces ',
},
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
name: 'entityWithSpaces',
label: 'Entity With Spaces',
description: 'Description with spaces',
});
});
it('should handle long text content', async () => {
const longDescription = 'A'.repeat(1000);
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: 'longDescEntity',
label: 'Long Description Entity',
description: longDescription,
},
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
description: longDescription,
});
});
});
```
**Test coverage requirements**:
- ✅ Minimal required input
- ✅ All optional fields
- ✅ Input sanitization
- ✅ Long text content
- ✅ Special characters
---
## Step 4: Update and Delete Tests
Create similar test files for update and delete operations:
**Required files**:
- `failing-my-entity-update.integration-spec.ts`
- `successful-my-entity-update.integration-spec.ts`
- `failing-my-entity-deletion.integration-spec.ts`
- `successful-my-entity-deletion.integration-spec.ts`
---
## Testing Best Practices
### Pattern: Cleanup
```typescript
afterEach(async () => {
if (createdEntityId) {
await deleteMyEntity({
expectToFail: false,
input: { id: createdEntityId },
});
}
});
```
### Pattern: Type-Safe Inputs
```typescript
const input = {
name: 'myEntity',
label: 'My Entity',
} as const satisfies CreateMyEntityInput;
```
### Pattern: Snapshot Testing
```typescript
expectOneNotInternalServerErrorSnapshot({
errors,
});
```
---
## Running Tests
```bash
# Run all entity tests
npx jest test/integration/metadata/suites/my-entity --config=packages/twenty-server/jest.config.mjs
# Run specific test file
npx jest test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts --config=packages/twenty-server/jest.config.mjs
# Update snapshots
npx jest test/integration/metadata/suites/my-entity --updateSnapshot --config=packages/twenty-server/jest.config.mjs
```
---
## Complete Test Checklist
### Test Utilities
- [ ] `create-my-entity-query-factory.util.ts` created
- [ ] `create-my-entity.util.ts` created
- [ ] `update-my-entity-query-factory.util.ts` created
- [ ] `update-my-entity.util.ts` created
- [ ] `delete-my-entity-query-factory.util.ts` created
- [ ] `delete-my-entity.util.ts` created
### Failing Tests Coverage
- [ ] Missing required fields
- [ ] Empty string validation
- [ ] Uniqueness violations
- [ ] Standard entity protection
- [ ] Foreign key validation
- [ ] JSONB property validation (if applicable)
### Successful Tests Coverage
- [ ] Create with minimal input
- [ ] Create with all optional fields
- [ ] Input sanitization (whitespace)
- [ ] Long text content
- [ ] Update single field
- [ ] Update multiple fields
- [ ] Successful deletion
### Snapshot Tests
- [ ] All failing tests use `expectOneNotInternalServerErrorSnapshot`
- [ ] Snapshots committed to `__snapshots__/` directory
---
## Success Criteria
Your integration tests are complete when:
✅ All test utilities created (minimum 6 files)
✅ Failing creation tests cover all validators
✅ Failing update tests cover business rules
✅ Failing deletion tests cover protection rules
✅ Successful tests cover all use cases
✅ All snapshots generated and committed
✅ All tests pass consistently
✅ Test coverage meets requirements (>80%)
---
## Final Step
**Step 6 Complete!** → Your syncable entity is fully tested and production-ready!
**Congratulations!** You've successfully created a new syncable entity in Twenty's workspace migration system.
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,340 +0,0 @@
---
name: syncable-entity-types-and-constants
description: Define types, entities, and central constant registrations for syncable entities in Twenty's workspace migration system. Use when creating new syncable entities, defining TypeORM entities, flat entity types, or registering in central constants (ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME, ALL_ONE_TO_MANY_METADATA_RELATIONS, ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY, ALL_MANY_TO_ONE_METADATA_RELATIONS).
---
# Syncable Entity: Types & Constants (Step 1/6)
**Purpose**: Define all types, entities, and register in central constants. This is the foundation - everything else depends on these types being correct.
**When to use**: First step when creating any new syncable entity. Must be completed before other steps.
---
## Quick Start
This step creates:
1. Metadata name constant (twenty-shared)
2. TypeORM entity (extends `SyncableEntity`)
3. Flat entity types
4. Action types (universal + flat)
5. Central constant registrations (5 constants)
---
## Step 1: Add Metadata Name
**File**: `packages/twenty-shared/src/metadata/all-metadata-name.constant.ts`
```typescript
export const ALL_METADATA_NAME = {
// ... existing entries
myEntity: 'myEntity',
} as const;
```
---
## Step 2: Create TypeORM Entity
**File**: `src/engine/metadata-modules/my-entity/entities/my-entity.entity.ts`
```typescript
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'myEntity' })
export class MyEntityEntity extends SyncableEntity {
@Column({ type: 'varchar' })
name: string;
@Column({ type: 'varchar' })
label: string;
@Column({ type: 'boolean', default: true })
isCustom: boolean;
// Foreign key example (optional)
@Column({ type: 'uuid', nullable: true })
parentEntityId: string | null;
@ManyToOne(() => ParentEntityEntity, { nullable: true })
@JoinColumn({ name: 'parentEntityId' })
parentEntity: ParentEntityEntity | null;
// JSONB column example (optional)
@Column({ type: 'jsonb', nullable: true })
settings: Record<string, any> | null;
}
```
**Key rules**:
- Must extend `SyncableEntity` (provides `id`, `universalIdentifier`, `applicationId`, etc.)
- Must have `isCustom` boolean column
- Use `@Column({ type: 'jsonb' })` for JSON data
---
## Step 3: Define Flat Entity Types
**File**: `src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type.ts`
```typescript
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
export type FlatMyEntity = FlatEntityFrom<MyEntityEntity>;
```
**Maps file** (if entity has indexed lookups):
```typescript
// flat-my-entity-maps.type.ts
export type FlatMyEntityMaps = {
byId: Record<string, FlatMyEntity>;
byName: Record<string, FlatMyEntity>;
// Add other indexes as needed
};
```
---
## Step 4: Define Editable Properties
**File**: `src/engine/metadata-modules/flat-my-entity/constants/editable-flat-my-entity-properties.constant.ts`
```typescript
export const EDITABLE_FLAT_MY_ENTITY_PROPERTIES = [
'name',
'label',
'description',
'parentEntityId',
'settings',
] as const satisfies ReadonlyArray<keyof FlatMyEntity>;
```
**Rule**: Only include properties that can be updated (exclude `id`, `createdAt`, `universalIdentifier`, etc.)
---
## Step 5: Define Action Types
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type.ts`
```typescript
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
// Universal actions (used by builder/runner)
export type UniversalCreateMyEntityAction = {
type: 'create';
metadataName: 'myEntity';
universalFlatEntity: UniversalFlatMyEntity;
};
export type UniversalUpdateMyEntityAction = {
type: 'update';
metadataName: 'myEntity';
universalFlatEntity: UniversalFlatMyEntity;
universalUpdates: Partial<UniversalFlatMyEntity>;
};
export type UniversalDeleteMyEntityAction = {
type: 'delete';
metadataName: 'myEntity';
universalFlatEntity: UniversalFlatMyEntity;
};
// Flat actions (internal to runner)
export type FlatCreateMyEntityAction = {
type: 'create';
metadataName: 'myEntity';
flatEntity: FlatMyEntity;
};
export type FlatUpdateMyEntityAction = {
type: 'update';
metadataName: 'myEntity';
flatEntity: FlatMyEntity;
updates: Partial<FlatMyEntity>;
};
export type FlatDeleteMyEntityAction = {
type: 'delete';
metadataName: 'myEntity';
flatEntity: FlatMyEntity;
};
```
---
## Step 6: Register in Central Constants
### 6a. AllFlatEntityTypesByMetadataName
**File**: `src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts`
```typescript
export type AllFlatEntityTypesByMetadataName = {
// ... existing entries
myEntity: {
flatEntityMaps: FlatMyEntityMaps;
universalActions: {
create: UniversalCreateMyEntityAction;
update: UniversalUpdateMyEntityAction;
delete: UniversalDeleteMyEntityAction;
};
flatActions: {
create: FlatCreateMyEntityAction;
update: FlatUpdateMyEntityAction;
delete: FlatDeleteMyEntityAction;
};
flatEntity: FlatMyEntity;
universalFlatEntity: UniversalFlatMyEntity;
entity: MyEntityEntity;
};
};
```
### 6b. ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME
**File**: `src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts`
```typescript
export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
// ... existing entries
myEntity: {
name: { toCompare: true },
label: { toCompare: true },
description: { toCompare: true },
parentEntityId: {
toCompare: true,
universalProperty: 'parentEntityUniversalIdentifier',
},
settings: {
toCompare: true,
toStringify: true,
universalProperty: 'universalSettings',
},
},
} as const;
```
**Rules**:
- `toCompare: true` → Editable property (checked for changes)
- `toStringify: true` → JSONB/object property (needs JSON serialization)
- `universalProperty` → Maps to universal version (for foreign keys & JSONB with `SerializedRelation`)
### 6c. ALL_ONE_TO_MANY_METADATA_RELATIONS
**File**: `src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts`
This constant is **type-checked** — values for `metadataName`, `flatEntityForeignKeyAggregator`, and `universalFlatEntityForeignKeyAggregator` are derived from entity type definitions. The aggregator names follow the pattern: remove trailing `'s'` from the relation property name, then append `Ids` or `UniversalIdentifiers`.
```typescript
export const ALL_ONE_TO_MANY_METADATA_RELATIONS = {
// ... existing entries
myEntity: {
// If myEntity has a `childEntities: ChildEntityEntity[]` property:
childEntities: {
metadataName: 'childEntity',
flatEntityForeignKeyAggregator: 'childEntityIds',
universalFlatEntityForeignKeyAggregator: 'childEntityUniversalIdentifiers',
},
// null for relations to non-syncable entities
someNonSyncableRelation: null,
},
} as const;
```
### 6d. ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY
**File**: `src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts`
Low-level primitive constant. Only contains `foreignKey` — the column name ending in `Id` that stores the foreign key. Type-checked against entity properties.
```typescript
export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
// ... existing entries
myEntity: {
workspace: null,
application: null,
parentEntity: {
foreignKey: 'parentEntityId',
},
},
} as const;
```
### 6e. ALL_MANY_TO_ONE_METADATA_RELATIONS
**File**: `src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts`
Derived from both `ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY` (for `foreignKey` type and `universalForeignKey` derivation) and `ALL_ONE_TO_MANY_METADATA_RELATIONS` (for `inverseOneToManyProperty` key constraint). This is the main constant consumed by utils and optimistic tooling.
```typescript
export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
// ... existing entries
myEntity: {
workspace: null,
application: null,
parentEntity: {
metadataName: 'parentEntity',
foreignKey: 'parentEntityId',
inverseOneToManyProperty: 'myEntities', // key in ALL_ONE_TO_MANY_METADATA_RELATIONS['parentEntity'], or null if no inverse
isNullable: false,
universalForeignKey: 'parentEntityUniversalIdentifier',
},
},
} as const;
```
**Derivation dependency graph**:
```
ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY ALL_ONE_TO_MANY_METADATA_RELATIONS
(foreignKey only) (metadataName, aggregators)
│ │
│ FK type + universalFK derivation │ inverseOneToManyProperty keys
│ │
└────────────────┬───────────────────────┘
ALL_MANY_TO_ONE_METADATA_RELATIONS
(metadataName, foreignKey, inverseOneToManyProperty,
isNullable, universalForeignKey)
```
**Rules**:
- `workspace: null`, `application: null` — always present, always null (non-syncable relations)
- `inverseOneToManyProperty` — must be a key in `ALL_ONE_TO_MANY_METADATA_RELATIONS[targetMetadataName]`, or `null` if the target entity doesn't expose an inverse one-to-many relation
- `universalForeignKey` — derived from `foreignKey` by replacing the `Id` suffix with `UniversalIdentifier`
- Optimistic utils resolve `flatEntityForeignKeyAggregator` / `universalFlatEntityForeignKeyAggregator` at runtime by looking up `inverseOneToManyProperty` in `ALL_ONE_TO_MANY_METADATA_RELATIONS`
---
## Checklist
Before moving to Step 2:
- [ ] Metadata name added to `ALL_METADATA_NAME`
- [ ] TypeORM entity created (extends `SyncableEntity`)
- [ ] `isCustom` column added
- [ ] Flat entity type defined
- [ ] Flat entity maps type defined (if needed)
- [ ] Editable properties constant defined
- [ ] Universal and flat action types defined
- [ ] Registered in `AllFlatEntityTypesByMetadataName`
- [ ] Registered in `ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME`
- [ ] Registered in `ALL_ONE_TO_MANY_METADATA_RELATIONS` (if entity has one-to-many relations)
- [ ] Registered in `ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY`
- [ ] Registered in `ALL_MANY_TO_ONE_METADATA_RELATIONS`
- [ ] TypeScript compiles without errors
---
## Next Step
Once all types and constants are defined, proceed to:
**[Syncable Entity: Cache & Transform (Step 2/6)](../syncable-entity-cache-and-transform/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
+1 -7
View File
@@ -19,10 +19,4 @@ runs:
uses: nrwl/nx-set-shas@v4
- name: Run affected command
shell: bash
env:
NX_CONFIGURATION: ${{ inputs.configuration }}
NX_TASKS: ${{ inputs.tasks }}
NX_PARALLEL: ${{ inputs.parallel }}
NX_TAG: ${{ inputs.tag }}
NX_ARGS: ${{ inputs.args }}
run: npx nx affected --nxBail --configuration="$NX_CONFIGURATION" -t="$NX_TASKS" --parallel="$NX_PARALLEL" --exclude="*,!tag:$NX_TAG" $NX_ARGS
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}' ${{ inputs.args }}
+1 -4
View File
@@ -19,11 +19,8 @@ runs:
- name: Cache primary key builder
id: cache-primary-key-builder
shell: bash
env:
CACHE_KEY: ${{ inputs.key }}
REF_NAME: ${{ github.ref_name }}
run: |
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${CACHE_KEY}-${REF_NAME}" >> "${GITHUB_OUTPUT}"
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
uses: actions/cache/restore@v4
id: restore-cache
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
deploy-main:
timeout-minutes: 3
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
deploy-tag:
timeout-minutes: 3
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
+1 -1
View File
@@ -16,7 +16,7 @@ permissions:
jobs:
changed-files:
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
outputs:
any_changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
+189 -9
View File
@@ -16,6 +16,8 @@ env:
permissions:
contents: read
pull-requests: write
checks: write
jobs:
changed-files-check:
@@ -32,7 +34,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 45
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -582,16 +584,182 @@ jobs:
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Upload breaking changes report
- name: Comment API Changes on PR
if: always()
uses: actions/upload-artifact@v4
uses: actions/github-script@v7
with:
name: breaking-changes-report
path: |
*-diff.md
*-diff.json
if-no-files-found: ignore
retention-days: 3
script: |
const fs = require('fs');
let hasChanges = false;
let comment = '';
try {
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
}
}
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += restDiff + '\n\n';
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += metadataDiff + '\n\n';
}
}
// Only post comment if there are changes
if (hasChanges) {
// Add branch state information only if there were conflicts
const branchState = process.env.BRANCH_STATE || 'unknown';
let branchStateNote = '';
if (branchState === 'conflicts') {
branchStateNote = '\n\n⚠️ **Note**: Could not merge with `main` due to conflicts. This comparison shows changes between the current branch and `main` as separate states.\n';
}
// Check if there are any breaking changes detected
let hasBreakingChanges = false;
let breakingChangeNote = '';
// Check for breaking changes in any of the diff files
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
// Also check GraphQL changes for breaking changes indicators
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.includes('Breaking changes') || graphqlDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
// Check PR title for "breaking"
const prTitle = ${{ toJSON(github.event.pull_request.title) }};
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
if (hasBreakingChanges) {
if (titleContainsBreaking) {
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
'**This PR title contains "breaking" and breaking changes were detected - the CI will fail as expected.**\n\n' +
'📝 **Action Required**: Please add `BREAKING CHANGE:` to your commit message to trigger a major version bump.\n\n' +
'Example:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
} else {
breakingChangeNote = '\n\n## ⚠️ Breaking Change Protocol\n\n' +
'**Breaking changes detected but PR title does not contain "breaking" - CI will pass but action needed.**\n\n' +
'🔄 **Options**:\n' +
'1. **If this IS a breaking change**: Add "breaking" to your PR title and add `BREAKING CHANGE:` to your commit message\n' +
'2. **If this is NOT a breaking change**: The API diff tool may have false positives - please review carefully\n\n' +
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
}
}
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const commentBody = COMMENT_MARKER + '\n' + comment + branchStateNote + '\n⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
// Get all comments to find existing API changes comment
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
// Find our existing comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing API changes comment');
} else {
// Create new comment
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
console.log('Created new API changes comment');
}
} else {
console.log('No API changes detected - skipping PR comment');
// Check if there's an existing comment to remove
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
console.log('Deleted existing API changes comment (no changes detected)');
}
}
} catch (error) {
console.log('Could not post comment:', error);
}
- name: Cleanup servers
if: always()
@@ -603,4 +771,16 @@ jobs:
kill $(cat /tmp/main-server.pid) || true
fi
- name: Upload API specifications and diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: api-specifications-and-diffs
path: |
/tmp/main-server.log
/tmp/current-server.log
*-api.json
*-schema-introspection.json
*-diff.md
*-diff.json
+2 -3
View File
@@ -20,12 +20,11 @@ jobs:
with:
files: |
packages/create-twenty-app/**
!packages/create-twenty-app/package.json
create-app-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
@@ -50,7 +49,7 @@ jobs:
ci-create-app-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-test]
steps:
- name: Fail job if any needs failed
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -56,7 +56,7 @@ jobs:
ci-emails-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, emails-test]
steps:
- name: Fail job if any needs failed
+72 -80
View File
@@ -14,8 +14,8 @@ concurrency:
env:
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-ubuntu-latest-8-cores-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-ubuntu-latest-8-cores-runner-${{ github.ref_name }}-${{ github.sha }}
jobs:
changed-files-check:
@@ -28,21 +28,18 @@ jobs:
packages/twenty-ui/**
packages/twenty-shared/**
packages/twenty-sdk/**
!packages/twenty-sdk/package.json
changed-files-check-e2e:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
playwright.config.ts
.github/workflows/ci-front.yaml
front-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
steps:
@@ -68,7 +65,7 @@ jobs:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
front-sb-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: front-sb-build
strategy:
fail-fast: false
@@ -98,52 +95,52 @@ jobs:
run: npx nx reset:env twenty-front
- name: Run storybook tests
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
# - name: Rename coverage file
# run: |
# if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
# mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# else
# echo "Error: coverage-final.json not found"
# ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
# exit 1
# fi
# - name: Upload coverage artifact
# uses: actions/upload-artifact@v4
# with:
# retention-days: 1
# name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
# path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# merge-reports-and-check-coverage:
# timeout-minutes: 30
# runs-on: depot-ubuntu-24.04
# needs: front-sb-test
# env:
# PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
# strategy:
# matrix:
# storybook_scope: [modules, pages, performance]
# steps:
# - uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - uses: actions/download-artifact@v4
# with:
# pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
# merge-multiple: true
# path: coverage-artifacts
# - name: Merge coverage reports
# run: |
# mkdir -p ${{ env.PATH_TO_COVERAGE }}
# npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
# - name: Checking coverage
# run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
- name: Rename coverage file
run: |
if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
else
echo "Error: coverage-final.json not found"
ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
exit 1
fi
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
retention-days: 1
name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
merge-reports-and-check-coverage:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: front-sb-test
env:
PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
strategy:
matrix:
storybook_scope: [modules, pages, performance]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- uses: actions/download-artifact@v4
with:
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
merge-multiple: true
path: coverage-artifacts
- name: Merge coverage reports
run: |
mkdir -p ${{ env.PATH_TO_COVERAGE }}
npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
- name: Checking coverage
run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
front-chromatic-deployment:
timeout-minutes: 30
if: false
needs: front-sb-build
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
env:
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
@@ -169,9 +166,8 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=4096'
TASK_CACHE_KEY: front-task-${{ matrix.task }}
strategy:
matrix:
@@ -198,7 +194,6 @@ jobs:
tag: scope:frontend
tasks: reset:env
- name: Run ${{ matrix.task }} task
id: run-task
uses: ./.github/actions/nx-affected
with:
tag: scope:frontend
@@ -211,7 +206,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
env:
NODE_OPTIONS: "--max-old-space-size=10240"
steps:
@@ -229,14 +224,14 @@ jobs:
run: npx nx reset:env twenty-front
- name: Build frontend
run: npx nx build twenty-front
# - name: Upload frontend build artifact
# uses: actions/upload-artifact@v4
# with:
# name: frontend-build
# path: packages/twenty-front/build
# retention-days: 1
- name: Upload frontend build artifact
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: packages/twenty-front/build
retention-days: 1
e2e-test:
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, front-build]
if: |
always() &&
@@ -296,18 +291,15 @@ jobs:
cp packages/twenty-front/.env.example packages/twenty-front/.env
npx nx reset:env:e2e-testing-server twenty-server
# - name: Download frontend build artifact
# if: needs.front-build.result == 'success'
# uses: actions/download-artifact@v4
# with:
# name: frontend-build
# path: packages/twenty-front/build
- name: Download frontend build artifact
if: needs.front-build.result == 'success'
uses: actions/download-artifact@v4
with:
name: frontend-build
path: packages/twenty-front/build
# - name: Build frontend (if not available from front-build)
# if: needs.front-build.result == 'skipped'
# run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
- name: Build frontend
- name: Build frontend (if not available from front-build)
if: needs.front-build.result == 'skipped'
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
- name: Build server
@@ -339,23 +331,23 @@ jobs:
- name: Run Playwright tests
run: npx nx test twenty-e2e-testing
# - uses: actions/upload-artifact@v4
# if: always()
# with:
# name: playwright-report
# path: packages/twenty-e2e-testing/run_results/
# retention-days: 30
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: packages/twenty-e2e-testing/run_results/
retention-days: 30
ci-front-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs:
[
changed-files-check,
front-task,
front-build,
# merge-reports-and-check-coverage,
merge-reports-and-check-coverage,
front-sb-test,
front-sb-build,
]
@@ -366,7 +358,7 @@ jobs:
ci-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, e2e-test]
steps:
- name: Fail job if any needs failed
+1 -1
View File
@@ -25,7 +25,7 @@ defaults:
jobs:
create_pr:
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -15,7 +15,7 @@ defaults:
jobs:
tag_and_release:
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
steps:
- name: Check PR Author
+60 -51
View File
@@ -18,15 +18,14 @@ jobs:
with:
files: |
packages/twenty-sdk/**
!packages/twenty-sdk/package.json
sdk-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
task: [lint, typecheck, test:unit]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -40,62 +39,72 @@ jobs:
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- name: Install Playwright
if: contains(matrix.task, 'storybook')
run: npx playwright install chromium
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
NODE_ENV: test
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: SDK / Run e2e Tests
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: test:e2e
# TODO uncomment when syncApplication resolver is fixed
# sdk-e2e-integration-test:
# timeout-minutes: 30
# runs-on: ubuntu-latest-8-cores
# needs: [changed-files-check, sdk-test]
# strategy:
# matrix:
# task: [test:integration, test:e2e]
# if: needs.changed-files-check.outputs.any_changed == 'true'
# services:
# postgres:
# image: twentycrm/twenty-postgres-spilo
# env:
# PGUSER_SUPERUSER: postgres
# PGPASSWORD_SUPERUSER: postgres
# ALLOW_NOSSL: 'true'
# SPILO_PROVIDER: 'local'
# ports:
# - 5432:5432
# options: >-
# --health-cmd pg_isready
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# redis:
# image: redis
# ports:
# - 6379:6379
# env:
# NODE_ENV: test
# steps:
# - name: Fetch custom Github Actions and base branch history
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - name: Server / Append billing config to .env.test
# working-directory: packages/twenty-server
# run: |
# echo "" >> .env.test
# echo "IS_BILLING_ENABLED=true" >> .env.test
# echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
# echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
# echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
# echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
# - name: Server / Create Test DB
# run: |
# PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
# - name: SDK / Run ${{ matrix.task }} Tests
# uses: ./.github/actions/nx-affected
# with:
# tag: scope:sdk
# tasks: ${{ matrix.task }}
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, sdk-test, sdk-e2e-test]
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test]
# TODO uncomment when syncApplication resolver is fixed
# needs: [changed-files-check, sdk-test, sdk-e2e-integration-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -143,7 +143,7 @@ jobs:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: server-setup
steps:
- name: Fetch custom Github Actions and base branch history
@@ -164,7 +164,7 @@ jobs:
server-integration-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: server-setup
strategy:
fail-fast: false
@@ -250,7 +250,7 @@ jobs:
ci-server-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, server-setup, server-test, server-integration-test]
steps:
- name: Fail job if any needs failed
+2 -4
View File
@@ -22,9 +22,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
env:
NODE_OPTIONS: '--max-old-space-size=4096'
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
@@ -47,7 +45,7 @@ jobs:
ci-shared-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, shared-test]
steps:
- name: Fail job if any needs failed
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -92,7 +92,7 @@ jobs:
ci-test-docker-compose-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, test]
steps:
- name: Fail job if any needs failed
+5 -2
View File
@@ -9,7 +9,10 @@ on:
types: [opened, synchronize, reopened, closed]
permissions:
actions: write
checks: write
contents: write
issues: write
pull-requests: write
statuses: write
@@ -22,7 +25,7 @@ concurrency:
jobs:
danger-js:
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
if: github.event.action != 'closed'
steps:
- uses: actions/checkout@v4
@@ -35,7 +38,7 @@ jobs:
congratulate:
timeout-minutes: 3
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
if: github.event.action == 'closed' && github.event.pull_request.merged == true
steps:
- uses: actions/checkout@v4
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -65,7 +65,7 @@ jobs:
ci-website-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, website-build]
steps:
- name: Fail job if any needs failed
-128
View File
@@ -1,128 +0,0 @@
name: CI Zapier
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
SERVER_SETUP_CACHE_KEY: server-setup
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-zapier/**
packages/twenty-server/**
!packages/twenty-zapier/package.json
!packages/twenty-zapier/CHANGELOG.md
server-setup:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Write .env
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Server / Build
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Server / Start
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start worker
run: |
npx nx run twenty-server:worker &
echo "Worker started"
- name: Zapier / Build
run: npx nx build twenty-zapier
- name: Zapier / Run Tests
uses: ./.github/actions/nx-affected
with:
tag: scope:zapier
tasks: test
zapier-test:
needs: server-setup
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
strategy:
matrix:
task: [lint, typecheck, validate]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-zapier
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:zapier
tasks: ${{ matrix.task }}
ci-zapier-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, zapier-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+16 -9
View File
@@ -23,7 +23,7 @@ jobs:
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.user.type != 'Bot') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
@@ -87,7 +87,7 @@ jobs:
exit 0
fi
ISSUE_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}"
ENCODED_BRANCH=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$BRANCH")
ENCODED_BRANCH=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$BRANCH', safe=''))")
PR_URL="https://github.com/${{ github.repository }}/compare/main...${ENCODED_BRANCH}?quick_pull=1"
BODY="⚠️ Claude ran out of turns before creating a PR. Work has been pushed to [\`$BRANCH\`](https://github.com/${{ github.repository }}/tree/$ENCODED_BRANCH).\n\n[**Create PR →**]($PR_URL)"
if [ -n "$ISSUE_NUMBER" ]; then
@@ -96,7 +96,7 @@ jobs:
claude-cross-repo:
if: github.event_name == 'repository_dispatch'
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
@@ -157,11 +157,18 @@ jobs:
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Dispatch response to ci-privileged
- name: Post response to source issue
if: always()
uses: peter-evans/repository-dispatch@v2
uses: actions/github-script@v7
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: claude-cross-repo-response
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": ${{ toJSON(format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id)) }}}'
github-token: ${{ secrets.TWENTY_DISPATCH_TOKEN }}
script: |
const [owner, repo] = '${{ steps.prompt.outputs.repo }}'.split('/');
const issueNumber = parseInt('${{ steps.prompt.outputs.issue_number }}', 10);
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Claude finished processing this request. [See workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
});
+1 -1
View File
@@ -34,7 +34,7 @@ concurrency:
jobs:
pull_docs_translations:
name: Pull docs translations
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -21,7 +21,7 @@ concurrency:
jobs:
push_docs:
name: Push documentation to Crowdin
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -32,7 +32,7 @@ concurrency:
jobs:
pull_translations:
name: Pull translations
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -17,7 +17,7 @@ concurrency:
jobs:
extract_translations:
name: Extract and upload translations
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+118
View File
@@ -0,0 +1,118 @@
# Weekly translation QA report using Crowdin's native QA checks
name: 'Weekly Translation QA Report'
permissions:
contents: write
pull-requests: write
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9am UTC
workflow_dispatch: # Allow manual trigger
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
qa_report:
name: Generate QA Report
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Generate QA report from Crowdin
id: generate_report
run: |
npx ts-node packages/twenty-utils/translation-qa-report.ts || true
if [ -f TRANSLATION_QA_REPORT.md ]; then
echo "report_generated=true" >> $GITHUB_OUTPUT
# Count critical issues (exclude spellcheck)
CRITICAL=$(grep -oP '⚠️\s+\K\d+' TRANSLATION_QA_REPORT.md 2>/dev/null || echo "0")
echo "critical_issues=$CRITICAL" >> $GITHUB_OUTPUT
else
echo "report_generated=false" >> $GITHUB_OUTPUT
echo "critical_issues=0" >> $GITHUB_OUTPUT
fi
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Create QA branch and commit report
if: steps.generate_report.outputs.report_generated == 'true'
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
BRANCH_NAME="i18n-qa-report-$(date +%Y-%m-%d)"
git checkout -B $BRANCH_NAME
git add TRANSLATION_QA_REPORT.md
if ! git diff --staged --quiet --exit-code; then
git commit -m "docs: weekly translation QA report"
git push origin HEAD:$BRANCH_NAME --force
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
else
echo "No changes to commit"
echo "BRANCH_NAME=" >> $GITHUB_ENV
fi
- name: Create pull request
if: steps.generate_report.outputs.report_generated == 'true' && env.BRANCH_NAME != ''
run: |
CRITICAL="${{ steps.generate_report.outputs.critical_issues }}"
BODY=$(cat <<EOF
## Weekly Translation QA Report
**Critical issues (excluding spellcheck): $CRITICAL**
📊 **View in Crowdin**: https://twenty.crowdin.com/u/projects/1/all?filter=qa-issue
### For AI-Assisted Fixing
Open this PR in Cursor and say:
> "Fix the translation QA issues using the Crowdin API"
The AI can help fix:
- ✅ Variables mismatch (missing/wrong placeholders)
- ✅ Escaped Unicode sequences
- ⚠️ Tags mismatch
- ⚠️ Empty translations
### Available Scripts
\`\`\`bash
# View QA report
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/translation-qa-report.ts
# Fix encoding issues automatically
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
\`\`\`
---
*Close without merging after issues are addressed*
EOF
)
EXISTING_PR=$(gh pr list --head $BRANCH_NAME --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
gh pr edit $EXISTING_PR --body "$BODY"
else
gh pr create \
--base main \
--head $BRANCH_NAME \
--title "i18n: Translation QA Report ($CRITICAL critical issues)" \
--body "$BODY" || true
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-71
View File
@@ -1,71 +0,0 @@
name: Post CI Comments
on:
workflow_run:
workflows: ['GraphQL and OpenAPI Breaking Changes Detection']
types: [completed]
permissions:
actions: read
jobs:
dispatch-breaking-changes:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Get PR number from workflow run
id: pr-info
uses: actions/github-script@v7
with:
script: |
const runId = context.payload.workflow_run.id;
const headSha = context.payload.workflow_run.head_sha;
const headBranch = context.payload.workflow_run.head_branch;
const headRepo = context.payload.workflow_run.head_repository;
// workflow_run.pull_requests is empty for fork PRs,
// so fall back to searching by head SHA
let pullRequests = context.payload.workflow_run.pull_requests;
let prNumber;
if (pullRequests && pullRequests.length > 0) {
prNumber = pullRequests[0].number;
} else {
core.info(`pull_requests is empty (likely a fork PR), searching by SHA ${headSha}`);
const owner = context.repo.owner;
const repo = context.repo.repo;
const headLabel = `${headRepo.owner.login}:${headBranch}`;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
head: headLabel,
per_page: 1,
});
if (prs.length > 0) {
prNumber = prs[0].number;
}
}
if (!prNumber) {
core.info('No pull request found for this workflow run');
core.setOutput('has_pr', 'false');
return;
}
core.setOutput('pr_number', prNumber);
core.setOutput('run_id', runId);
core.setOutput('has_pr', 'true');
core.info(`PR #${prNumber}, Run ID: ${runId}`);
- name: Dispatch to ci-privileged
if: steps.pr-info.outputs.has_pr == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: breaking-changes-report
client-payload: '{"pr_number": ${{ toJSON(steps.pr-info.outputs.pr_number) }}, "run_id": ${{ toJSON(steps.pr-info.outputs.run_id) }}, "repo": ${{ toJSON(github.repository) }}, "branch_state": ${{ toJSON(github.event.workflow_run.head_branch) }}}'
+7 -22
View File
@@ -2,8 +2,13 @@ name: 'Preview Environment Dispatch'
permissions:
contents: write
actions: write
pull-requests: read
on:
# Using pull_request_target instead of pull_request to have access to secrets for external contributors
# Security note: This is safe because we're only using the repository-dispatch action with limited scope
# and not checking out or running any code from the external contributor's PR
pull_request_target:
types: [opened, synchronize, reopened, labeled]
paths:
@@ -19,21 +24,9 @@ concurrency:
jobs:
trigger-preview:
if: |
(github.event.action == 'labeled' && github.event.label.name == 'preview-app') ||
(
(
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
) && (
github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
github.event.action == 'reopened'
)
)
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || (github.event.action == 'labeled' && github.event.label.name == 'preview-app')
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Trigger preview environment workflow
uses: peter-evans/repository-dispatch@v2
@@ -42,11 +35,3 @@ jobs:
repository: ${{ github.repository }}
event-type: preview-environment
client-payload: '{"pr_number": "${{ github.event.pull_request.number }}", "pr_head_sha": "${{ github.event.pull_request.head.sha }}", "repo_full_name": "${{ github.repository }}"}'
- name: Dispatch to ci-privileged for PR comment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: preview-env-url
client-payload: '{"pr_number": ${{ toJSON(github.event.pull_request.number) }}, "keepalive_dispatch_time": ${{ toJSON(github.event.pull_request.updated_at) }}, "repo": ${{ toJSON(github.repository) }}}'
+62 -34
View File
@@ -2,6 +2,7 @@ name: 'Preview Environment Keep Alive'
permissions:
contents: read
pull-requests: write
on:
repository_dispatch:
@@ -16,7 +17,7 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
@@ -24,17 +25,17 @@ jobs:
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "" >> packages/twenty-docker/.env
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
@@ -45,25 +46,24 @@ jobs:
cd packages/twenty-docker/
docker compose build
working-directory: ./
- name: Create Tunnel
id: expose-tunnel
uses: codetalkio/expose-tunnel@v1.5.0
with:
service: bore.pub
port: 3000
- name: Start services with correct SERVER_URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
cd packages/twenty-docker/
echo "Setting SERVER_URL to $TUNNEL_URL"
# Update the SERVER_URL with the tunnel URL
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
sed -i '/SERVER_URL=/d' .env
echo "" >> .env
echo "SERVER_URL=$TUNNEL_URL" >> .env
echo "SERVER_URL=${{ steps.expose-tunnel.outputs.tunnel-url }}" >> .env
# Start the services
echo "Docker compose up..."
docker compose up -d || {
@@ -71,7 +71,7 @@ jobs:
docker compose logs
exit 1
}
echo "Waiting for services to be ready..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
@@ -84,7 +84,7 @@ jobs:
fi
echo "Still waiting for services... ($count/60)"
done
echo "All services are up and running!"
working-directory: ./
@@ -99,33 +99,61 @@ jobs:
fi
working-directory: ./
- name: Output tunnel URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
- name: Output tunnel URL to logs
run: |
echo "✅ Preview Environment Ready!"
echo "🔗 Preview URL: $TUNNEL_URL"
echo "🔗 Preview URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}"
echo "⏱️ This environment will be available for 5 hours"
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@v4
- name: Post comment on PR
uses: actions/github-script@v6
with:
name: tunnel-url
path: tunnel-url.txt
retention-days: 1
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const COMMENT_MARKER = '<!-- PR_PREVIEW_ENV -->';
const commentBody = `${COMMENT_MARKER}
🚀 **Preview Environment Ready!**
Your preview environment is available at: ${{ steps.expose-tunnel.outputs.tunnel-url }}
This environment will automatically shut down when the PR is closed or after 5 hours.`;
// Get all comments
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
});
// Find our comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing comment');
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
body: commentBody
});
console.log('Created new comment');
}
- name: Keep tunnel alive for 5 hours
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
- name: Cleanup
if: always()
run: |
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
working-directory: ./
-2
View File
@@ -10,7 +10,6 @@
.nx/installation
.nx/cache
.nx/workspace-data
.nx/nxw.js
.pnp.*
.yarn/*
@@ -50,4 +49,3 @@ dump.rdb
mcp.json
/.junie/
TRANSLATION_QA_REPORT.md
.playwright-mcp/
+115
View File
@@ -0,0 +1,115 @@
"use strict";
// This file should be committed to your repository! It wraps Nx and ensures
// that your local installation matches nx.json.
// See: https://nx.dev/recipes/installation/install-non-javascript for more info.
Object.defineProperty(exports, "__esModule", { value: true });
const fs = require('fs');
const path = require('path');
const cp = require('child_process');
const installationPath = path.join(__dirname, 'installation', 'package.json');
function matchesCurrentNxInstall(currentInstallation, nxJsonInstallation) {
if (!currentInstallation.devDependencies ||
!Object.keys(currentInstallation.devDependencies).length) {
return false;
}
try {
if (currentInstallation.devDependencies['nx'] !==
nxJsonInstallation.version ||
require(path.join(path.dirname(installationPath), 'node_modules', 'nx', 'package.json')).version !== nxJsonInstallation.version) {
return false;
}
for (const [plugin, desiredVersion] of Object.entries(nxJsonInstallation.plugins || {})) {
if (currentInstallation.devDependencies[plugin] !== desiredVersion) {
return false;
}
}
return true;
}
catch {
return false;
}
}
function ensureDir(p) {
if (!fs.existsSync(p)) {
fs.mkdirSync(p, { recursive: true });
}
}
function getCurrentInstallation() {
try {
return require(installationPath);
}
catch {
return {
name: 'nx-installation',
version: '0.0.0',
devDependencies: {},
};
}
}
function performInstallation(currentInstallation, nxJson) {
fs.writeFileSync(installationPath, JSON.stringify({
name: 'nx-installation',
devDependencies: {
nx: nxJson.installation.version,
...nxJson.installation.plugins,
},
}));
try {
cp.execSync('npm i', {
cwd: path.dirname(installationPath),
stdio: 'inherit',
});
}
catch (e) {
// revert possible changes to the current installation
fs.writeFileSync(installationPath, JSON.stringify(currentInstallation));
// rethrow
throw e;
}
}
function ensureUpToDateInstallation() {
const nxJsonPath = path.join(__dirname, '..', 'nx.json');
let nxJson;
try {
nxJson = require(nxJsonPath);
if (!nxJson.installation) {
console.error('[NX]: The "installation" entry in the "nx.json" file is required when running the nx wrapper. See https://nx.dev/recipes/installation/install-non-javascript');
process.exit(1);
}
}
catch {
console.error('[NX]: The "nx.json" file is required when running the nx wrapper. See https://nx.dev/recipes/installation/install-non-javascript');
process.exit(1);
}
try {
ensureDir(path.join(__dirname, 'installation'));
const currentInstallation = getCurrentInstallation();
if (!matchesCurrentNxInstall(currentInstallation, nxJson.installation)) {
performInstallation(currentInstallation, nxJson);
}
}
catch (e) {
const messageLines = [
'[NX]: Nx wrapper failed to synchronize installation.',
];
if (e instanceof Error) {
messageLines.push('');
messageLines.push(e.message);
messageLines.push(e.stack);
}
else {
messageLines.push(e.toString());
}
console.error(messageLines.join('\n'));
process.exit(1);
}
}
if (!process.env.NX_WRAPPER_SKIP_INSTALL) {
ensureUpToDateInstallation();
}
require('./installation/node_modules/nx/bin/nx');
+2 -4
View File
@@ -28,8 +28,6 @@ npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
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
@@ -90,7 +88,7 @@ npx nx run twenty-front:graphql:generate --configuration=metadata
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Emotion (styling), Vite
- **Frontend**: React 18, TypeScript, Recoil (state management), Emotion (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
@@ -138,7 +136,7 @@ packages/
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- **Recoil** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
+2 -3
View File
@@ -28,7 +28,7 @@ See:
🚀 [Self-hosting](https://docs.twenty.com/developers/self-hosting/docker-compose)
🖥️ [Local Setup](https://docs.twenty.com/developers/local-setup)
# Why Twenty
# Does the world need another CRM?
We built Twenty for three reasons:
@@ -109,7 +109,7 @@ Below are a few features we have implemented to date:
- [TypeScript](https://www.typescriptlang.org/)
- [Nx](https://nx.dev/)
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
- [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
@@ -120,7 +120,6 @@ Below are a few features we have implemented to date:
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="30" alt="Greptile" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="30" alt="Sentry" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="30" alt="Crowdin" /></a>
<a href="https://e2b.dev/"><img src="./packages/twenty-website/public/images/readme/e2b.svg" height="30" alt="E2B" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
-381
View File
@@ -1,381 +0,0 @@
# Emotion → Linaria Migration Plan: twenty-front
## Overview
Migrate all Emotion (`@emotion/styled`, `@emotion/react`) usages in
`packages/twenty-front/src` to Linaria (`@linaria/react`, `@linaria/core`),
following the same patterns already established in the `twenty-ui` package.
Linaria is a **zero-runtime** CSS-in-JS library. Styles are extracted at
build time by [wyw-in-js](https://wyw-in-js.dev/) (the Vite plugin is
`@wyw-in-js/vite`, already configured in `twenty-front/vite.config.ts`).
This means every expression inside a `styled` or `css` template literal
must be statically evaluable at build time — no runtime theme objects,
no closures over component state, no side-effects.
**Total files to migrate: ~998**
| Category | Files | Description |
|---|---|---|
| styled-only | 694 | Import `@emotion/styled` but not `useTheme` |
| styled + useTheme | 224 | Import both `@emotion/styled` and `useTheme` |
| useTheme-only | 79 | Import `useTheme` but not `@emotion/styled` |
| css / Global only | 1 | Import `css` or `Global` from `@emotion/react` only |
## Theme Architecture
Two build-time utilities produce the theme system:
- **`buildThemeReferencingRootCssVariables`** — walks the theme object and
builds a nested mirror where every leaf is a `var(--t-xxx)` string
(evaluated at build time by wyw-in-js)
- **`prepareThemeForRootCssVariableInjection`** — walks the runtime theme
and collects flat `[--css-variable-name, value]` pairs, injected onto
`document.documentElement` by `ThemeCssVariableInjectorEffect`
`themeCssVariables` is the build-time object; every leaf resolves to a CSS
`var()` reference. It is safe to use inside `styled` and `css` templates
because wyw-in-js can evaluate it statically.
## Migration Patterns
### 1. `styled` import
```diff
- import styled from '@emotion/styled';
+ import { styled } from '@linaria/react';
```
### 2. Theme access in styled components
Replace Emotion's `({ theme }) =>` prop-function pattern with static
`themeCssVariables` references:
```diff
+ import { themeCssVariables } from 'twenty-ui/theme';
const StyledTitle = styled.span`
- color: ${({ theme }) => theme.font.color.primary};
- font-size: ${({ theme }) => theme.font.size.lg};
+ color: ${themeCssVariables.font.color.primary};
+ font-size: ${themeCssVariables.font.size.lg};
`;
```
### 3. Spacing
`theme.spacing(N)` is a function; in Linaria it becomes an indexed lookup:
```diff
- margin-top: ${({ theme }) => theme.spacing(3)};
+ margin-top: ${themeCssVariables.spacing[3]};
```
For multi-arg spacing like `theme.spacing(2, 4)``"8px 16px"`:
```diff
- padding: ${({ theme }) => theme.spacing(2, 4)};
+ padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[4]};
```
The spacing scale covers integers 032 plus `0.5` and `1.5`. Any other
fractional values (`0.25`, `0.75`, `1.25`, `2.5`, `3.5`) must be replaced
with literal pixel values (e.g. `theme.spacing(2.5)``10px`).
### 4. `useTheme` → `useContext(ThemeContext)`
For runtime theme access (icon sizes, animation durations, conditional logic
outside of styled components):
```diff
- import { useTheme } from '@emotion/react';
+ import { useContext } from 'react';
+ import { ThemeContext } from 'twenty-ui/theme';
const MyComponent = () => {
- const theme = useTheme();
+ const { theme } = useContext(ThemeContext);
return <Icon size={theme.icon.size.sm} />;
};
```
### 5. `css` template literal
Linaria's `css` (from `@linaria/core`) returns a **class name string**, not
a serialized style object like Emotion's `css`. This has two consequences:
**Standalone usage** — apply via `className`, not the `css` prop:
```diff
- import { css } from '@emotion/react';
+ import { css } from '@linaria/core';
const myClass = css`
text-decoration: none;
`;
- <Link css={myClass} />
+ <Link className={myClass} />
```
**Inside `styled` templates** — do NOT nest `css` tags. Linaria's `css`
returns a class name, not raw CSS text, so interpolating it inside `styled`
produces broken output. Use plain strings instead:
```diff
// WRONG — css`` returns a class name, not CSS text
${({ handle }) =>
handle === 'left'
- ? css`left: ${themeCssVariables.spacing[1]};`
- : css`right: ${themeCssVariables.spacing[1]};`}
+ ? `left: ${themeCssVariables.spacing[1]};`
+ : `right: ${themeCssVariables.spacing[1]};`}
```
### 6. Interpolation return types
wyw-in-js requires prop interpolation functions to return `string | number`.
They must **never** return `false`, `undefined`, or `null`. Replace
short-circuit `&&` with ternary expressions:
```diff
// WRONG — returns false when condition is false
- ${({ isActive }) => isActive && `background: ${themeCssVariables.color.blue};`}
// CORRECT
+ ${({ isActive }) => isActive ? `background: ${themeCssVariables.color.blue};` : ''}
```
### 7. Block interpolations (multi-declaration returns)
Linaria wraps each interpolation result in a single CSS custom property
(`var(--xxx)`). An interpolation that returns **multiple CSS declarations**
produces invalid CSS. Split into one interpolation per property:
```diff
// WRONG — single interpolation returning multiple declarations
- ${({ divider, theme }) => {
- const border = `1px solid ${theme.border.color.light}`;
- return divider === 'left' ? `border-left: ${border}` : `border-right: ${border}`;
- }}
// CORRECT — one interpolation per property
+ border-left: ${({ divider }) =>
+ divider === 'left' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
+ border-right: ${({ divider }) =>
+ divider === 'right' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
```
### 8. CSS var + unit concatenation
CSS custom properties can't be concatenated with unit suffixes directly
(`var(--x)px` is invalid). Use `calc()` to attach units:
```diff
- transition: background ${themeCssVariables.animation.duration.instant}s ease;
+ transition: background calc(${themeCssVariables.animation.duration.instant} * 1s) ease;
```
### 9. `styled(Component)` requires `className`
Linaria's `styled(Component)` works by passing a generated `className` to
the wrapped component. The component **must** accept and forward a
`className` prop — otherwise the styles are silently lost. If the component
doesn't support it, either add `className` support or use a wrapper div.
Linaria also does **not** support Emotion's `shouldForwardProp` option.
Custom props on HTML elements are automatically filtered by Linaria's
runtime (via `@emotion/is-prop-valid`). For custom components, all props are
forwarded — ensure the wrapped component ignores unknown props gracefully.
### 10. `type Theme` → `type ThemeType`
```diff
- import { type Theme } from '@emotion/react';
+ import { type ThemeType } from 'twenty-ui/theme';
```
### 11. Framer Motion integration
Linaria doesn't support `styled(motion.div)` — wrapping a motion element
with `styled()` causes the component body to be stripped at build time by
wyw-in-js. Define the styled component first, then wrap with
`motion.create()`:
```tsx
const StyledBarBase = styled.div`
background-color: ${themeCssVariables.font.color.primary};
height: 100%;
`;
const StyledBar = motion.create(StyledBarBase);
```
### 12. Dynamic styles via CSS variables
When a component needs to compute styles from multiple props with complex
branching logic (e.g. combining `variant`, `accent`, `disabled`, `focus`),
Linaria's prop interpolations become unwieldy. Use a `computeDynamicStyles`
helper that returns a `CSSProperties` object injected via `style={}`,
referenced from the static CSS with `var()`:
```tsx
const StyledButton = styled.button`
background: var(--btn-bg);
border-color: var(--btn-border-color);
&:hover { background: var(--btn-hover-bg); }
`;
const dynamicStyles = useMemo(() => {
const s = computeButtonDynamicStyles(variant, accent, ...);
return {
'--btn-bg': s.background,
'--btn-hover-bg': s.hoverBackground,
} as CSSProperties;
}, [variant, accent, ...]);
return <StyledButton style={dynamicStyles} />;
```
### 13. `Global` component
Replace Emotion's `<Global styles={...} />` with standard CSS or the
`ThemeCssVariableInjectorEffect` pattern from twenty-ui.
### 14. `ThemeProvider`
The `BaseThemeProvider` already wraps children with both Emotion's
`ThemeProvider` and Linaria's `ThemeContextProvider`. Once all Emotion usages
are gone, the Emotion `ThemeProvider` wrapper can be removed.
---
## PR Breakdown
Files are grouped to keep each PR around ~100 files with consistent review
surface. We start with the simplest, lowest-risk modules.
### PR 1 (~97 files) — Small standalone modules
Low-risk modules with mostly simple `styled`-only patterns.
| Module | Files |
|---|---|
| spreadsheet-import | 28 |
| billing | 10 |
| views | 14 |
| navigation-menu-item | 14 |
| blocknote-editor | 7 |
| advanced-text-editor | 7 |
| favorites | 7 |
| navigation | 4 |
| information-banner | 3 |
| sign-in-background-mock | 3 |
### PR 2 (~100 files) — Auth, tiny modules, loading, testing, pages (part 1)
| Module | Files |
|---|---|
| auth | 19 |
| action-menu | 3 |
| object-metadata | 3 |
| onboarding | 2 |
| workspace | 2 |
| file | 2 |
| error-handler | 2 |
| front-components | 1 |
| geo-map | 1 |
| hooks | 1 |
| loading | 5 |
| testing | 5 |
| pages (first ~55 files) | ~55 |
### PR 3 (~97 files) — Pages (remaining) + activities + AI
| Module | Files |
|---|---|
| pages (remaining ~16 files) | ~16 |
| activities | 53 |
| ai | 28 |
### PR 4 (~100 files) — Command-menu + workflow (part 1)
| Module | Files |
|---|---|
| command-menu | 53 |
| workflow (first ~47 files) | ~47 |
### PR 5 (~115 files) — Workflow (remaining) + page-layout
| Module | Files |
|---|---|
| workflow (remaining ~32 files) | ~32 |
| page-layout | 83 |
### PR 6 (~100 files) — UI module (part 1)
| Module | Files |
|---|---|
| ui (first ~100 files) | ~100 |
### PR 7 (~85 files) — UI module (remaining) + object-record (start)
| Module | Files |
|---|---|
| ui (remaining ~23 files) | ~23 |
| object-record (first ~62 files) | ~62 |
### PR 8 (~100 files) — Object-record (continued)
| Module | Files |
|---|---|
| object-record (next ~100 files) | ~100 |
### PR 9 (~100 files) — Settings (part 1)
| Module | Files |
|---|---|
| settings (first ~100 files) | ~100 |
### PR 10 (~102 files) — Settings (part 2) + final cleanup
| Module | Files |
|---|---|
| settings (remaining ~102 files) | ~102 |
| css/Global-only file | 1 |
### Post-migration PR — Remove Emotion
Once all PRs are merged:
- Remove `ThemeProvider` from `@emotion/react` in `BaseThemeProvider`
- Remove `@emotion/styled` and `@emotion/react` dependencies
- Remove `@styled/typescript-styled-plugin` from tsconfig
- Clean up any remaining Emotion-related configuration
---
## Risk Assessment
| Risk | Mitigation |
|---|---|
| wyw-in-js evaluates at build time; dynamic expressions may fail | Use `themeCssVariables` for static theme values; pass dynamic values as component props or via `style={}` CSS variables |
| `theme.spacing(N)` function → `themeCssVariables.spacing[N]` index | Pre-computed for integers 032 plus 0.5 and 1.5; other fractional values → literal pixel values |
| `useTheme` used for runtime logic (not just styles) | Replace with `useContext(ThemeContext)`, destructure `{ theme }` |
| Multi-arg `theme.spacing(a, b, c)` | Split into individual `themeCssVariables.spacing[N]` references |
| `css` tag inside `styled` templates | Linaria `css` returns class name, not CSS text; use plain strings inside `styled` |
| Interpolation returns `false` / `undefined` | wyw-in-js requires `string \| number`; use ternary `? : ''` instead of `&&` |
| Block interpolations (multiple declarations) | Split into one interpolation per CSS property |
| `var(--x)px` concatenation | Use `calc(var(--x) * 1px)` |
| `styled(motion.div)` stripped by wyw-in-js | Use `motion.create(StyledBase)` pattern |
| `styled(Component)` with no `className` prop | Add `className` support to wrapped component or use wrapper div |
| Complex multi-prop style branching | Use `computeDynamicStyles` + `style={}` + `var()` references |
---
## Validation Checklist (per PR)
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx test twenty-front` passes
- [ ] Visual spot-check of affected components in the app
- [ ] No remaining `@emotion/styled` or `@emotion/react` imports in migrated files
+2 -12
View File
@@ -11,9 +11,7 @@ import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
const twentyRules = await nxPlugin.loadWorkspaceRules(
'packages/twenty-eslint-rules',
);
const twentyRules = await nxPlugin.loadWorkspaceRules('packages/twenty-eslint-rules');
export default [
// Base JavaScript configuration
@@ -67,10 +65,6 @@ export default [
sourceTag: 'scope:sdk',
onlyDependOnLibsWithTags: ['scope:sdk', 'scope:shared'],
},
{
sourceTag: 'scope:create-app',
onlyDependOnLibsWithTags: ['scope:create-app', 'scope:shared'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
@@ -83,10 +77,6 @@ export default [
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:zapier'],
},
],
},
],
@@ -207,7 +197,7 @@ export default [
plugins: {
...mdxPlugin.flat.plugins,
'@nx': nxPlugin,
twenty: { rules: twentyRules },
'twenty': { rules: twentyRules },
},
},
mdxPlugin.flatCodeBlocks,
-283
View File
@@ -1,283 +0,0 @@
# npm-Based App Distribution for Twenty
*Technical Design Document -- February 2026*
## Overview
Add npm registry support for distributing Twenty apps (public and private), with per-AppRegistration registry overrides, direct tarball upload as escape hatch, and version upgrade detection.
## Assumptions
- The marketplace install flow (currently a TODO in the resolver and frontend) will be implemented separately. This plan provides the infrastructure that install flow will call into.
- The existing `app:dev` flow (individual file uploads via CLI) remains unchanged.
- The existing GitHub-based marketplace discovery remains as a curated fallback.
## Architecture
```
Developer
/ | \
npm publish | twenty app:push
/ | \
npmjs.com Private Reg Server REST Upload
| | |
v v v
[Discovery Layer] [Direct Upload]
npm search API |
GitHub curated list |
| |
v |
MarketplaceService |
(sourcePackage in DTO) |
| |
v v
AppPackageResolverService <--------+
.npmrc generation
yarn add / tarball extract
|
v
ApplicationSyncService (existing)
WorkspaceMigrationRunnerService
|
v
AppRegistration + Application entities
```
## Phase 1: Entity and Config Changes
### 1a. Extend AppRegistrationEntity
Add four columns to `application-registration.entity.ts`:
- **`sourcePackage`** (text, nullable) -- npm package name, e.g. `"twenty-app-fireflies"` or `"@myorg/twenty-app-crm"`. Null for tarball-only or OAuth-only apps.
- **`tarballFileId`** (uuid, nullable) -- FK to a FileEntity storing a directly-uploaded `.tar.gz`. Null when the app comes from npm.
- **`registryUrl`** (text, nullable) -- per-registration npm registry override. Null means "use the server default `APP_REGISTRY_URL`." This is how a single server can pull public apps from npmjs.com while pulling `@mycompany/*` apps from GitHub Packages.
- **`latestAvailableVersion`** (text, nullable) -- cached latest version from the registry, updated periodically. Compared against `Application.version` to surface upgrade availability.
**Source resolution priority:**
1. `sourcePackage` is set → resolve from npm via `yarn add`
2. `tarballFileId` is set → extract from file storage
3. Neither → OAuth-only app, no server-side code
### 1b. Extend ApplicationEntity.sourceType
Widen the `sourceType` union from `'local'` to `'local' | 'npm' | 'tarball'`:
- `'local'` -- existing behavior (CLI `app:dev`, individual file uploads, workspace-custom)
- `'npm'` -- installed from an npm registry via `yarn add`
- `'tarball'` -- installed from a directly-uploaded tarball
This lets the system distinguish how an app was installed, which matters for upgrade logic (npm apps can check the registry for newer versions; tarball apps cannot).
### 1c. Add server-wide config variables
Add a new `APP_REGISTRY_CONFIG` group to ConfigVariablesGroup:
- **`APP_REGISTRY_URL`** (string, default `https://registry.npmjs.org`) -- default npm registry URL
- **`APP_REGISTRY_TOKEN`** (string, optional, sensitive) -- auth token for the default registry
### 1d. Generate migration
TypeORM migration adding `sourcePackage`, `tarballFileId`, `registryUrl`, `latestAvailableVersion` to `core.applicationRegistration`.
## Phase 2: App Package Resolver Service
### 2a. Create AppPackageResolverService
New service with core method:
```
resolvePackage(appRegistration, options?: { targetVersion? }) → ResolvedPackage | null
```
Returns `{ manifestPath, packageJsonPath, filesDir }` or null for OAuth-only apps.
**Resolution logic:**
```
if sourcePackage:
1. Determine registry: appRegistration.registryUrl ?? APP_REGISTRY_URL
2. Determine auth token for the resolved registry
3. Generate temporary .npmrc in an isolated working directory
4. Run: yarn add <sourcePackage>@<targetVersion ?? latest>
5. Read manifest from node_modules/<sourcePackage>/.twenty/output/manifest.json
6. Return paths
if tarballFileId:
1. Download tarball from FileStorageService
2. Extract to temporary directory
3. Read manifest from extracted files
4. Return paths
else:
return null (OAuth-only)
```
### 2b. Isolated working directories
Each resolution runs in a temporary directory under `{os.tmpdir()}/twenty-app-resolver/{uuid}/`. This avoids contaminating the server's own `node_modules` and isolates apps from each other. Cleaned up after files are copied to storage.
### 2c. .npmrc generation
For scoped packages (`@scope/twenty-app-*`):
```
@scope:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=TOKEN
```
For unscoped packages with a non-default registry:
```
registry=https://my-verdaccio.internal:4873
//my-verdaccio.internal:4873/:_authToken=TOKEN
```
### 2d. Post-resolution file transfer
After resolving, copies files into the app's storage path using the existing FileStorageService layout:
```
{workspaceId}/{applicationUniversalIdentifier}/
built-logic-function/...
built-front-component/...
dependencies/package.json
dependencies/yarn.lock
public-asset/...
source/...
```
This reuses the same FileFolder enum paths that `app:dev` uses, so downstream ApplicationSyncService works unchanged.
## Phase 3: Marketplace Discovery via npm
### 3a. Update MarketplaceService
Add npm-based discovery alongside the existing GitHub path:
- Query npm search API: `GET {registryUrl}/-/v1/search?text=keywords:twenty-app&size=250`
- Map each result to MarketplaceAppDTO using package.json metadata
**Merge strategy:**
1. Fetch from npm search API (apps with `keywords: ["twenty-app"]`)
2. Fetch from GitHub (existing curated list)
3. Merge by `universalIdentifier` -- GitHub entries override npm entries (allowing curation)
4. Cache merged result with existing 1-hour TTL
### 3b. Add sourcePackage to MarketplaceAppDTO
The DTO needs a `sourcePackage: string | null` field so the install flow knows which npm package to resolve. For npm-discovered apps, this is the package name. For GitHub-only apps, this is null.
## Phase 4: Version Upgrade Support
### 4a. Create AppUpgradeService
**Periodic version check (npm-sourced apps only):**
- Fetches `{registryUrl}/{sourcePackage}/latest` from the npm registry
- Stores result in `AppRegistration.latestAvailableVersion`
- Frontend compares against `Application.version` to show "Update available"
**Upgrade trigger:**
1. Resolve the new version via AppPackageResolverService
2. Sync via existing ApplicationSyncService (triggers workspace migration for schema changes)
3. Update Application.version
**Rollback strategy:** If sync fails (e.g., migration validation error), re-resolve the previous version and re-sync. This is possible because npm retains all published versions.
### 4b. Version check scheduling
Lightweight cron or check-on-access pattern. Iterates over AppRegistrations where `sourcePackage IS NOT NULL` and calls `checkForUpdates()`. Frequency: once per hour, matching the existing marketplace cache TTL.
## Phase 5: SDK CLI Commands
### 5a. Finalize `twenty app:build`
Ensure `.twenty/output/` is npm-publishable. The build step generates a `package.json` in the output directory:
```json
{
"name": "twenty-app-fireflies",
"version": "1.2.0",
"keywords": ["twenty-app"],
"twenty": {
"universalIdentifier": "a4df0c0f-c65e-44e5-8436-24814182d4ac"
},
"files": ["manifest.json", "built-logic-function", "built-front-component", "public-asset"]
}
```
The developer then publishes with standard `npm publish` -- no custom command needed.
### 5b. `twenty app:pack` (new command)
```
twenty app:pack [appPath]
```
- Runs `app:build` if `.twenty/output/` doesn't exist or is stale
- Uses existing TarballService to create `{name}-{version}.tar.gz`
- Outputs the file path for manual distribution
### 5c. `twenty app:push` (new command)
```
twenty app:push [appPath] --server <url> --token <token>
```
- Runs `app:pack` to produce the tarball
- Reads universalIdentifier from manifest to find or create the AppRegistration
- Uploads via `POST /api/app-registrations/upload-tarball`
- Reports success with the registration ID
- Reuses `twenty auth:login` credentials if `--server` is not specified
## Phase 6: Server Tarball Upload Endpoint
### 6a. REST controller
```
POST /api/app-registrations/upload-tarball
Content-Type: multipart/form-data
Body: tarball file + optional universalIdentifier
```
**Validation:**
- Max file size: 50MB
- Must be a valid `.tar.gz`
- Extracted contents must contain `manifest.json` with a valid `universalIdentifier`
- The `universalIdentifier` must not conflict with an existing registration owned by a different user
**Flow:**
1. Extract tarball to temp directory
2. Validate manifest structure
3. Find or create AppRegistration by universalIdentifier
4. Store tarball in FileStorageService under `FileFolder.AppTarball`
5. Set `tarballFileId` on the AppRegistration
6. Return the AppRegistration entity
### 6b. Add FileFolder.AppTarball
New enum value `AppTarball = 'app-tarball'` in FileFolder.
## Key Design Decisions
| Decision | Rationale |
|---|---|
| Per-AppRegistration registry override | `registryUrl` on the entity allows mixing registries. Public apps from npmjs.com, private from GitHub Packages/Verdaccio. Server-wide `APP_REGISTRY_URL` is the fallback. |
| npm publish is standard | No custom publish infra. Free versioning, README, `npm audit`, download stats, proven auth model. |
| Tarball as escape hatch | Air-gapped environments, CI pipelines, one-off installs. Cannot auto-upgrade. |
| sourceType distinction | `'npm' \| 'tarball' \| 'local'` lets the system know which upgrade path is available. Only npm apps can check for newer versions. |
| Backward compatible | `app:dev` flow unchanged. GitHub marketplace unchanged. All new fields nullable. |
| Upgrade rollback | Re-resolve previous version from npm on failure. Safe because npm never deletes published versions. |
## Edge Cases
- **npm unreachable**: Timeout after 30s, throw clear error. App remains at current installed version.
- **Package name conflicts**: The `universalIdentifier` in the `twenty` field of `package.json` is the source of truth, not the npm package name. Two packages with the same universalIdentifier conflict at the AppRegistration level (unique index).
- **Scoped vs unscoped packages**: Both work. Scoped packages naturally route to a private registry via `.npmrc` scope mapping.
- **Multiple workspaces, same server**: AppRegistration is server-level (core schema). Application is workspace-level. One AppRegistration can be installed in multiple workspaces at different versions.
Binary file not shown.
+4 -2
View File
@@ -118,7 +118,6 @@
"outputs": ["{projectRoot}/coverage"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs",
"silent": true,
"coverage": true,
"coverageReporters": ["text-summary"],
"cacheDirectory": "../../.cache/jest/{projectRoot}"
@@ -126,7 +125,7 @@
"configurations": {
"ci": {
"ci": true,
"maxWorkers": 1
"maxWorkers": 3
},
"coverage": {
"coverageReporters": ["lcov", "text"]
@@ -273,6 +272,9 @@
"inputs": ["default", "^default"]
}
},
"installation": {
"version": "22.3.3"
},
"generators": {
"@nx/react": {
"application": {
+19 -18
View File
@@ -22,7 +22,6 @@
"googleapis": "105",
"hex-rgb": "^5.0.0",
"immer": "^10.1.1",
"jotai": "^2.17.1",
"libphonenumber-js": "^1.10.26",
"lodash.camelcase": "^4.3.0",
"lodash.chunk": "^4.2.0",
@@ -48,7 +47,8 @@
"react-responsive": "^9.0.2",
"react-router-dom": "^6.4.4",
"react-tooltip": "^5.13.1",
"remark-gfm": "^4.0.1",
"recoil": "^0.7.7",
"remark-gfm": "^3.0.1",
"rxjs": "^7.2.0",
"semver": "^7.5.4",
"slash": "^5.1.0",
@@ -83,17 +83,17 @@
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-docs": "^10.2.13",
"@storybook/addon-links": "^10.2.13",
"@storybook/addon-vitest": "^10.2.13",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-links": "^10.1.11",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.2.13",
"@storybook/react-vite": "^10.1.11",
"@storybook/test-runner": "^0.24.2",
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.11.1",
"@swc-node/register": "1.8.0",
"@swc/cli": "^0.3.12",
"@swc/core": "1.15.11",
"@swc/helpers": "~0.5.18",
"@swc/core": "1.13.3",
"@swc/helpers": "~0.5.2",
"@swc/jest": "^0.2.39",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -136,10 +136,10 @@
"@typescript-eslint/parser": "^8.39.0",
"@typescript-eslint/utils": "^8.39.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitejs/plugin-react-swc": "4.2.3",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"@vitejs/plugin-react-swc": "3.11.0",
"@vitest/browser-playwright": "^4.0.17",
"@vitest/coverage-istanbul": "^4.0.17",
"@vitest/coverage-v8": "^4.0.17",
"@yarnpkg/types": "^4.0.0",
"chromatic": "^6.18.0",
"concurrently": "^8.2.2",
@@ -159,7 +159,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^10.2.13",
"eslint-plugin-storybook": "^10.1.11",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"http-server": "^14.1.1",
@@ -175,18 +175,17 @@
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.20",
"storybook": "^10.2.13",
"storybook": "^10.1.11",
"storybook-addon-mock-date": "2.0.0",
"storybook-addon-pseudo-states": "^10.2.13",
"storybook-addon-pseudo-states": "^10.1.11",
"supertest": "^6.1.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.2.3",
"ts-node": "10.9.1",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"vite": "^7.0.0",
"vitest": "^4.0.18"
"vitest": "^4.0.17"
},
"engines": {
"node": "^24.5.0",
@@ -201,6 +200,8 @@
"type-fest": "4.10.1",
"typescript": "5.9.2",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"prosemirror-view": "1.40.0",
"prosemirror-transform": "1.10.4",
"@lingui/core": "5.1.2",
"@types/qs": "6.9.16"
},
+25 -70
View File
@@ -15,7 +15,7 @@
Create Twenty App is the official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). It sets up a readytorun project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
- Zeroconfig project bootstrap
- Preconfigured scripts for auth, dev mode (watch & sync), uninstall, and function management
- Preconfigured scripts for auth, dev mode (watch & sync), generate, uninstall, and function management
- Strong TypeScript support and typed client generation
## Documentation
@@ -31,90 +31,45 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Get help and list all available commands
yarn twenty help
# If you don't use yarn@4
corepack enable
yarn install
# Get help
yarn run help
# Authenticate using your API key (you'll be prompted)
yarn twenty auth:login
yarn auth:login
# Add a new entity to your application (guided)
yarn twenty entity:add
yarn entity:add
# Generate a typed Twenty client and workspace entity types
yarn app:generate
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated)
yarn twenty app:dev
yarn app:dev
# Watch your application's function logs
yarn twenty function:logs
yarn function:logs
# Execute a function with a JSON payload
yarn twenty function:execute -n my-function -p '{"key": "value"}'
# Execute the pre-install function
yarn twenty function:execute --preInstall
# Execute the post-install function
yarn twenty function:execute --postInstall
yarn function:execute -n my-function -p '{"key": "value"}'
# Uninstall the application from the current workspace
yarn twenty app:uninstall
yarn app:uninstall
```
## Scaffolding modes
Control which example files are included when creating a new app:
| Flag | Behavior |
|------|----------|
| `-e, --exhaustive` | **(default)** Creates all example files without prompting |
| `-m, --minimal` | Creates only core files (`application-config.ts` and `default-role.ts`) |
| `-i, --interactive` | Prompts you to select which examples to include |
```bash
# Default: all examples included
npx create-twenty-app@latest my-app
# Minimal: only core files
npx create-twenty-app@latest my-app -m
# Interactive: choose which examples to include
npx create-twenty-app@latest my-app -i
```
In interactive mode, you can pick from:
- **Example object** — a custom CRM object definition (`objects/example-object.ts`)
- **Example field** — a custom field on the example object (`fields/example-field.ts`)
- **Example logic function** — a server-side handler with HTTP trigger (`logic-functions/hello-world.ts`)
- **Example front component** — a React UI component (`front-components/hello-world.tsx`)
- **Example view** — a saved view for the example object (`views/example-view.ts`)
- **Example navigation menu item** — a sidebar link (`navigation-menu-items/example-navigation-menu-item.ts`)
- **Example skill** — an AI agent skill definition (`skills/example-skill.ts`)
## What gets scaffolded
**Core files (always created):**
- `application-config.ts` — Application metadata configuration
- `roles/default-role.ts` — Default role for logic functions
- `logic-functions/pre-install.ts` — Pre-install logic function (runs before app installation)
- `logic-functions/post-install.ts` — Post-install logic function (runs after app installation)
- TypeScript configuration, ESLint, package.json, .gitignore
- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk
**Example files (controlled by scaffolding mode):**
- `objects/example-object.ts` — Example custom object with a text field
- `fields/example-field.ts` — Example standalone field extending the example object
- `logic-functions/hello-world.ts` — Example logic function with HTTP trigger
- `front-components/hello-world.tsx` — Example front component
- `views/example-view.ts` — Example saved view for the example object
- `navigation-menu-items/example-navigation-menu-item.ts` — Example sidebar navigation link
- `skills/example-skill.ts` — Example AI agent skill definition
- A minimal app structure ready for Twenty
- TypeScript configuration
- Prewired scripts that wrap the `twenty` CLI from twenty-sdk
- Example placeholders to help you add entities, actions, and sync logic
## Next steps
- Run `yarn twenty help` to see all available commands.
- Use `yarn twenty auth:login` to authenticate with your Twenty workspace.
- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- Two typed API clients are autogenerated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- Explore the generated project and add your first entity with `yarn entity:add` (functions, front components, objects, roles).
- Keep your types uptodate using `yarn app:generate`.
- Use `yarn app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
## Publish your application
@@ -142,8 +97,8 @@ git push
Our team reviews contributions for quality, security, and reusability before merging.
## Troubleshooting
- Auth prompts not appearing: run `yarn twenty auth:login` again and verify the API key permissions.
- Types not generated: ensure `yarn twenty app:dev` is running — it autogenerates the typed client.
- Auth prompts not appearing: run `yarn auth:login` again and verify the API key permissions.
- Types not generated: ensure `yarn app:generate` runs without errors, then restart `yarn app:dev`.
## Contributing
- See our [GitHub](https://github.com/twentyhq/twenty)
+100 -9
View File
@@ -1,20 +1,111 @@
import baseConfig from '../../eslint.config.mjs';
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import prettierPlugin from 'eslint-plugin-prettier';
export default [
...baseConfig,
js.configs.recommended,
{
ignores: ['**/dist/**'],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
globals: {
// Node.js globals
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
// Browser globals that Node.js also has
URL: 'readonly',
URLSearchParams: 'readonly',
// Node.js types
NodeJS: 'readonly',
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
prettier: prettierPlugin,
},
rules: {
...typescriptEslint.configs.recommended.rules,
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'no-useless-escape': 'off',
},
},
{
rules: {
'no-console': 'off',
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
},
},
ignores: ['src/**/*.ts', '!src/cli/**/*.ts'],
},
{
files: ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**/*.ts'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
globals: {
// Node.js globals
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
// Jest globals
describe: 'readonly',
it: 'readonly',
test: 'readonly',
expect: 'readonly',
jest: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
prettier: prettierPlugin,
},
rules: {
...typescriptEslint.configs.recommended.rules,
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'no-useless-escape': 'off',
},
},
{
ignores: ['dist/**', 'node_modules/**'],
},
];
+1 -2
View File
@@ -1,5 +1,5 @@
const jestConfig = {
displayName: 'create-twenty-app',
displayName: 'twenty-cli',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
@@ -15,7 +15,6 @@ const jestConfig = {
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^package.json$': '<rootDir>/package.json',
},
moduleFileExtensions: ['ts', 'js'],
extensionsToTreatAsEsm: ['.ts'],
+2 -7
View File
@@ -1,13 +1,11 @@
{
"name": "create-twenty-app",
"version": "0.6.3",
"version": "0.4.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
"files": [
"dist",
"README.md",
"package.json"
"dist/**/*"
],
"scripts": {
"build": "npx rimraf dist && npx vite build"
@@ -45,9 +43,6 @@
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"twenty-sdk": "workspace:*",
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1"
+11 -52
View File
@@ -2,7 +2,6 @@
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import { CreateAppCommand } from '@/create-app.command';
import { type ScaffoldingMode } from '@/types/scaffolding-options';
import packageJson from '../package.json';
const program = new Command(packageJson.name)
@@ -13,58 +12,18 @@ const program = new Command(packageJson.name)
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.option('-e, --exhaustive', 'Create all example entities (default)')
.option(
'-m, --minimal',
'Create only core entities (application-config and default-role)',
)
.option(
'-i, --interactive',
'Interactively choose which entity examples to include',
)
.helpOption('-h, --help', 'Display this help message.')
.action(
async (
directory?: string,
options?: {
exhaustive?: boolean;
minimal?: boolean;
interactive?: boolean;
},
) => {
const modeFlags = [
options?.exhaustive,
options?.minimal,
options?.interactive,
].filter(Boolean);
if (modeFlags.length > 1) {
console.error(
chalk.red(
'Error: --exhaustive, --minimal, and --interactive are mutually exclusive.',
),
);
process.exit(1);
}
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
console.error(
chalk.red(
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
),
);
process.exit(1);
}
const mode: ScaffoldingMode = options?.minimal
? 'minimal'
: options?.interactive
? 'interactive'
: 'exhaustive';
await new CreateAppCommand().execute(directory, mode);
},
);
.action(async (directory?: string) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
console.error(
chalk.red(
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
),
);
process.exit(1);
}
await new CreateAppCommand().execute(directory);
});
program.exitOverride();
@@ -1,12 +0,0 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
@@ -5,41 +5,36 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty auth:login
yarn auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
yarn app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
yarn auth:login # Authenticate with Twenty
yarn auth:logout # Remove credentials
yarn auth:status # Check auth status
yarn auth:switch # Switch default workspace
yarn auth:list # List all configured workspaces
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn app:dev # Start dev mode (watch, build, and sync)
yarn entity:add # Add a new entity (function, front-component, object, role)
yarn app:generate # Generate typed Twenty client
yarn function:logs # Stream function logs
yarn function:execute # Execute a function with JSON payload
yarn app:uninstall # Uninstall app from workspace
```
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
@@ -8,24 +8,14 @@ import inquirer from 'inquirer';
import kebabCase from 'lodash.kebabcase';
import * as path from 'path';
import {
type ExampleOptions,
type ScaffoldingMode,
} from '@/types/scaffolding-options';
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
export class CreateAppCommand {
async execute(
directory?: string,
mode: ScaffoldingMode = 'exhaustive',
): Promise<void> {
async execute(directory?: string): Promise<void> {
try {
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(directory);
const exampleOptions = await this.resolveExampleOptions(mode);
await this.validateDirectory(appDirectory);
this.logCreationInfo({ appDirectory, appName });
@@ -37,7 +27,6 @@ export class CreateAppCommand {
appDisplayName,
appDescription,
appDirectory,
exampleOptions,
});
await install(appDirectory);
@@ -103,103 +92,6 @@ export class CreateAppCommand {
return { appName, appDisplayName, appDirectory, appDescription };
}
private async resolveExampleOptions(
mode: ScaffoldingMode,
): Promise<ExampleOptions> {
if (mode === 'minimal') {
return {
includeExampleObject: false,
includeExampleField: false,
includeExampleLogicFunction: false,
includeExampleFrontComponent: false,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeExampleSkill: false,
};
}
if (mode === 'exhaustive') {
return {
includeExampleObject: true,
includeExampleField: true,
includeExampleLogicFunction: true,
includeExampleFrontComponent: true,
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
};
}
const { selectedExamples } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedExamples',
message: 'Select which example files to include:',
choices: [
{
name: 'Example object (custom object definition)',
value: 'object',
checked: true,
},
{
name: 'Example field (custom field on the example object)',
value: 'field',
checked: true,
},
{
name: 'Example logic function (server-side handler)',
value: 'logicFunction',
checked: true,
},
{
name: 'Example front component (React UI component)',
value: 'frontComponent',
checked: true,
},
{
name: 'Example view (saved view for the example object)',
value: 'view',
checked: true,
},
{
name: 'Example navigation menu item (sidebar link)',
value: 'navigationMenuItem',
checked: true,
},
{
name: 'Example skill (AI agent skill definition)',
value: 'skill',
checked: true,
},
],
},
]);
const includeField = selectedExamples.includes('field');
const includeView = selectedExamples.includes('view');
const includeObject =
selectedExamples.includes('object') || includeField || includeView;
if ((includeField || includeView) && !selectedExamples.includes('object')) {
console.log(
chalk.yellow(
'Note: Example object auto-included because example field/view depends on it.',
),
);
}
return {
includeExampleObject: includeObject,
includeExampleField: includeField,
includeExampleLogicFunction: selectedExamples.includes('logicFunction'),
includeExampleFrontComponent: selectedExamples.includes('frontComponent'),
includeExampleView: includeView,
includeExampleNavigationMenuItem:
selectedExamples.includes('navigationMenuItem'),
includeExampleSkill: selectedExamples.includes('skill'),
};
}
private async validateDirectory(appDirectory: string): Promise<void> {
if (!(await fs.pathExists(appDirectory))) {
return;
@@ -233,9 +125,9 @@ export class CreateAppCommand {
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(` cd ${dirName}`));
console.log(
chalk.gray(' yarn twenty auth:login # Authenticate with Twenty'),
);
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
console.log(chalk.gray(` corepack enable # if you don't use yarn@4`));
console.log(chalk.gray(` yarn install # if you don't use yarn@4`));
console.log(chalk.gray(' yarn auth:login # Authenticate with Twenty'));
console.log(chalk.gray(' yarn app:dev # Start dev mode'));
}
}
@@ -1,11 +0,0 @@
export type ScaffoldingMode = 'exhaustive' | 'minimal' | 'interactive';
export type ExampleOptions = {
includeExampleObject: boolean;
includeExampleField: boolean;
includeExampleLogicFunction: boolean;
includeExampleFrontComponent: boolean;
includeExampleView: boolean;
includeExampleNavigationMenuItem: boolean;
includeExampleSkill: boolean;
};
@@ -1,11 +1,9 @@
import { type ExampleOptions } from '@/types/scaffolding-options';
import { GENERATED_DIR } from 'twenty-shared/application';
import { copyBaseApplicationProject } from '@/utils/app-template';
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import { join } from 'path';
import createTwentyAppPackageJson from 'package.json';
import { tmpdir } from 'os';
import { copyBaseApplicationProject } from '@/utils/app-template';
// Mock fs-extra's copy function to skip copying base template (not available during tests)
jest.mock('fs-extra', () => {
const actual = jest.requireActual('fs-extra');
return {
@@ -14,33 +12,11 @@ jest.mock('fs-extra', () => {
};
});
const APPLICATION_FILE_NAME = 'application-config.ts';
const DEFAULT_ROLE_FILE_NAME = 'default-role.ts';
const ALL_EXAMPLES: ExampleOptions = {
includeExampleObject: true,
includeExampleField: true,
includeExampleLogicFunction: true,
includeExampleFrontComponent: true,
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
};
const NO_EXAMPLES: ExampleOptions = {
includeExampleObject: false,
includeExampleField: false,
includeExampleSkill: false,
includeExampleLogicFunction: false,
includeExampleFrontComponent: false,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
};
describe('copyBaseApplicationProject', () => {
let testAppDirectory: string;
beforeEach(async () => {
// Create a unique temp directory for each test
testAppDirectory = join(
tmpdir(),
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
@@ -50,6 +26,7 @@ describe('copyBaseApplicationProject', () => {
});
afterEach(async () => {
// Clean up temp directory after each test
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
@@ -61,16 +38,18 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
// Verify src/app/ folder exists
const srcAppPath = join(testAppDirectory, 'src');
expect(await fs.pathExists(srcAppPath)).toBe(true);
const appConfigPath = join(srcAppPath, APPLICATION_FILE_NAME);
// Verify application.config.ts exists in src/app/
const appConfigPath = join(srcAppPath, 'application.config.ts');
expect(await fs.pathExists(appConfigPath)).toBe(true);
const roleConfigPath = join(srcAppPath, 'roles', DEFAULT_ROLE_FILE_NAME);
// Verify default.role.ts exists in src/app/
const roleConfigPath = join(srcAppPath, 'default.role.ts');
expect(await fs.pathExists(roleConfigPath)).toBe(true);
});
@@ -80,7 +59,6 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const packageJsonPath = join(testAppDirectory, 'package.json');
@@ -89,10 +67,8 @@ describe('copyBaseApplicationProject', () => {
const packageJson = await fs.readJson(packageJsonPath);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.version).toBe('0.1.0');
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.scripts['twenty']).toBe('twenty');
expect(packageJson.dependencies['twenty-sdk']).toBe('0.4.0');
expect(packageJson.scripts['app:dev']).toBe('twenty app:dev');
});
it('should create .gitignore file', async () => {
@@ -101,7 +77,6 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const gitignorePath = join(testAppDirectory, '.gitignore');
@@ -109,7 +84,7 @@ describe('copyBaseApplicationProject', () => {
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
expect(gitignoreContent).toContain('/node_modules');
expect(gitignoreContent).toContain(GENERATED_DIR);
expect(gitignoreContent).toContain('generated');
});
it('should create yarn.lock file', async () => {
@@ -118,7 +93,6 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const yarnLockPath = join(testAppDirectory, 'yarn.lock');
@@ -128,74 +102,81 @@ describe('copyBaseApplicationProject', () => {
expect(yarnLockContent).toContain('yarn lockfile v1');
});
it('should create application-config.ts with defineApplication and correct values', async () => {
it('should create application.config.ts with defineApplication and correct values', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
const appConfigPath = join(
testAppDirectory,
'src',
'application.config.ts',
);
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
// Verify it uses defineApplication
expect(appConfigContent).toContain(
"import { defineApplication } from 'twenty-sdk'",
);
expect(appConfigContent).toContain('export default defineApplication({');
// Verify it imports the role identifier
expect(appConfigContent).toContain(
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/default.role'",
);
// Verify display name and description
expect(appConfigContent).toContain("displayName: 'My Test App'");
expect(appConfigContent).toContain("description: 'A test application'");
// Verify it has a universalIdentifier (UUID format)
expect(appConfigContent).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
// Verify it references the role
expect(appConfigContent).toContain(
'defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
});
it('should create default-role.ts with defineRole and correct values', async () => {
it('should create default.role.ts with defineRole and correct values', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const roleConfigPath = join(
testAppDirectory,
'src',
'roles',
DEFAULT_ROLE_FILE_NAME,
);
const roleConfigPath = join(testAppDirectory, 'src', 'default.role.ts');
const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8');
// Verify it uses defineRole
expect(roleConfigContent).toContain(
"import { defineRole } from 'twenty-sdk'",
);
expect(roleConfigContent).toContain('export default defineRole({');
// Verify it exports the universal identifier constant
expect(roleConfigContent).toContain(
'export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
// Verify role label includes app name
expect(roleConfigContent).toContain(
"label: 'My Test App default function role'",
);
// Verify default permissions
expect(roleConfigContent).toContain('canReadAllObjectRecords: true');
expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true');
expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true');
expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false');
// Verify it has a universalIdentifier (UUID format)
expect(roleConfigContent).toMatch(
/universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER/,
);
@@ -207,9 +188,9 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
// Verify fs.copy was called with correct destination
expect(fs.copy).toHaveBeenCalledTimes(1);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('base-application'),
@@ -223,16 +204,20 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'My Test App',
appDescription: '',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
const appConfigPath = join(
testAppDirectory,
'src',
'application.config.ts',
);
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
expect(appConfigContent).toContain("description: ''");
});
it('should generate unique UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
@@ -240,9 +225,9 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'App One',
appDescription: 'First app',
appDirectory: firstAppDir,
exampleOptions: ALL_EXAMPLES,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
@@ -250,18 +235,19 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
exampleOptions: ALL_EXAMPLES,
});
// Read both app configs
const firstAppConfig = await fs.readFile(
join(firstAppDir, 'src', APPLICATION_FILE_NAME),
join(firstAppDir, 'src', 'application.config.ts'),
'utf8',
);
const secondAppConfig = await fs.readFile(
join(secondAppDir, 'src', APPLICATION_FILE_NAME),
join(secondAppDir, 'src', 'application.config.ts'),
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
@@ -273,6 +259,7 @@ describe('copyBaseApplicationProject', () => {
});
it('should generate unique role UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
@@ -280,9 +267,9 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'App One',
appDescription: 'First app',
appDirectory: firstAppDir,
exampleOptions: ALL_EXAMPLES,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
@@ -290,19 +277,19 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
exampleOptions: ALL_EXAMPLES,
});
const firstRoleConfig = await fs.readFile(
join(firstAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
join(firstAppDir, 'src', 'default.role.ts'),
'utf8',
);
const secondRoleConfig = await fs.readFile(
join(secondAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
join(secondAppDir, 'src', 'default.role.ts'),
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstRoleConfig.match(uuidRegex)?.[1];
@@ -312,489 +299,4 @@ describe('copyBaseApplicationProject', () => {
expect(secondUuid).toBeDefined();
expect(firstUuid).not.toBe(secondUuid);
});
describe('scaffolding modes', () => {
describe('exhaustive mode (all examples)', () => {
it('should create all example files when all options are enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const srcPath = join(testAppDirectory, 'src');
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'fields', 'example-field.ts')),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(true);
expect(
await fs.pathExists(
join(
srcPath,
'navigation-menu-items',
'example-navigation-menu-item.ts',
),
),
).toBe(true);
// Install functions should always exist
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'pre-install.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'post-install.ts'),
),
).toBe(true);
});
});
describe('minimal mode (no examples)', () => {
it('should create only core files when no examples are enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const srcPath = join(testAppDirectory, 'src');
expect(await fs.pathExists(join(srcPath, APPLICATION_FILE_NAME))).toBe(
true,
);
expect(
await fs.pathExists(join(srcPath, 'roles', DEFAULT_ROLE_FILE_NAME)),
).toBe(true);
// Install functions should always exist (not gated by exampleOptions)
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'pre-install.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'post-install.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
expect(
await fs.pathExists(join(srcPath, 'fields', 'example-field.ts')),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(false);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(false);
expect(
await fs.pathExists(
join(
srcPath,
'navigation-menu-items',
'example-navigation-menu-item.ts',
),
),
).toBe(false);
});
});
describe('selective examples', () => {
it('should create only front component when only that option is enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: {
includeExampleObject: false,
includeExampleField: false,
includeExampleSkill: false,
includeExampleLogicFunction: false,
includeExampleFrontComponent: true,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
},
});
const srcPath = join(testAppDirectory, 'src');
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
expect(
await fs.pathExists(join(srcPath, 'fields', 'example-field.ts')),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(false);
});
it('should create only logic function when only that option is enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: {
includeExampleObject: false,
includeExampleSkill: false,
includeExampleField: false,
includeExampleLogicFunction: true,
includeExampleFrontComponent: false,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
},
});
const srcPath = join(testAppDirectory, 'src');
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
});
});
});
describe('example object', () => {
it('should create example-object.ts with defineObject and correct structure', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const objectPath = join(
testAppDirectory,
'src',
'objects',
'example-object.ts',
);
expect(await fs.pathExists(objectPath)).toBe(true);
const content = await fs.readFile(objectPath, 'utf8');
expect(content).toContain(
"import { defineObject, FieldType } from 'twenty-sdk'",
);
expect(content).toContain('export default defineObject({');
expect(content).toContain(
'export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER',
);
expect(content).toContain('export const NAME_FIELD_UNIVERSAL_IDENTIFIER');
expect(content).toContain("nameSingular: 'exampleItem'");
expect(content).toContain("namePlural: 'exampleItems'");
expect(content).toContain('FieldType.TEXT');
expect(content).toContain(
'labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER',
);
});
it('should generate unique UUIDs for example objects across apps', async () => {
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
appDescription: 'First app',
appDirectory: firstAppDir,
exampleOptions: ALL_EXAMPLES,
});
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
exampleOptions: ALL_EXAMPLES,
});
const firstContent = await fs.readFile(
join(firstAppDir, 'src', 'objects', 'example-object.ts'),
'utf8',
);
const secondContent = await fs.readFile(
join(secondAppDir, 'src', 'objects', 'example-object.ts'),
'utf8',
);
const uuidRegex =
/EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstContent.match(uuidRegex)?.[1];
const secondUuid = secondContent.match(uuidRegex)?.[1];
expect(firstUuid).toBeDefined();
expect(secondUuid).toBeDefined();
expect(firstUuid).not.toBe(secondUuid);
});
});
describe('example field', () => {
it('should create example-field.ts with defineField referencing the object', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const fieldPath = join(
testAppDirectory,
'src',
'fields',
'example-field.ts',
);
expect(await fs.pathExists(fieldPath)).toBe(true);
const content = await fs.readFile(fieldPath, 'utf8');
expect(content).toContain(
"import { defineField, FieldType } from 'twenty-sdk'",
);
expect(content).toContain(
"import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'",
);
expect(content).toContain('export default defineField({');
expect(content).toContain(
'objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER',
);
expect(content).toContain('FieldType.NUMBER');
expect(content).toContain("name: 'priority'");
});
});
describe('example view', () => {
it('should create example-view.ts with defineView referencing the object', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const viewPath = join(
testAppDirectory,
'src',
'views',
'example-view.ts',
);
expect(await fs.pathExists(viewPath)).toBe(true);
const content = await fs.readFile(viewPath, 'utf8');
expect(content).toContain("import { defineView } from 'twenty-sdk'");
expect(content).toContain(
"import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'",
);
expect(content).toContain('export default defineView({');
expect(content).toContain(
'objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER',
);
expect(content).toContain("name: 'example-view'");
});
});
describe('example navigation menu item', () => {
it('should create example-navigation-menu-item.ts with defineNavigationMenuItem', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const navPath = join(
testAppDirectory,
'src',
'navigation-menu-items',
'example-navigation-menu-item.ts',
);
expect(await fs.pathExists(navPath)).toBe(true);
const content = await fs.readFile(navPath, 'utf8');
expect(content).toContain(
"import { defineNavigationMenuItem } from 'twenty-sdk'",
);
expect(content).toContain('export default defineNavigationMenuItem({');
expect(content).toContain("name: 'example-navigation-menu-item'");
expect(content).toContain("icon: 'IconList'");
expect(content).toContain('position: 0');
});
});
describe('pre-install logic function', () => {
it('should create pre-install.ts with definePreInstallLogicFunction and typed payload', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const preInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'pre-install.ts',
);
expect(await fs.pathExists(preInstallPath)).toBe(true);
const content = await fs.readFile(preInstallPath, 'utf8');
expect(content).toContain(
"import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'",
);
expect(content).toContain(
'export default definePreInstallLogicFunction({',
);
expect(content).toContain("name: 'pre-install'");
expect(content).toContain('timeoutSeconds: 300');
expect(content).toContain(
'const handler = async (payload: InstallLogicFunctionPayload): Promise<void>',
);
expect(content).toContain('payload.previousVersion');
// Verify it has a universalIdentifier (UUID format)
expect(content).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
});
it('should always create pre-install.ts regardless of example options', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const preInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'pre-install.ts',
);
expect(await fs.pathExists(preInstallPath)).toBe(true);
});
});
describe('post-install logic function', () => {
it('should create post-install.ts with definePostInstallLogicFunction and typed payload', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const postInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'post-install.ts',
);
expect(await fs.pathExists(postInstallPath)).toBe(true);
const content = await fs.readFile(postInstallPath, 'utf8');
expect(content).toContain(
"import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'",
);
expect(content).toContain(
'export default definePostInstallLogicFunction({',
);
expect(content).toContain("name: 'post-install'");
expect(content).toContain('timeoutSeconds: 300');
expect(content).toContain(
'const handler = async (payload: InstallLogicFunctionPayload): Promise<void>',
);
expect(content).toContain('payload.previousVersion');
// Verify it has a universalIdentifier (UUID format)
expect(content).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
});
it('should always create post-install.ts regardless of example options', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const postInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'post-install.ts',
);
expect(await fs.pathExists(postInstallPath)).toBe(true);
});
});
});
@@ -3,9 +3,6 @@ import { join } from 'path';
import { v4 } from 'uuid';
import { ASSETS_DIR } from 'twenty-shared/application';
import { type ExampleOptions } from '@/types/scaffolding-options';
import createTwentyAppPackageJson from 'package.json';
const SRC_FOLDER = 'src';
export const copyBaseApplicationProject = async ({
@@ -13,13 +10,11 @@ export const copyBaseApplicationProject = async ({
appDisplayName,
appDescription,
appDirectory,
exampleOptions,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
exampleOptions: ExampleOptions;
}) => {
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
@@ -38,83 +33,20 @@ export const copyBaseApplicationProject = async ({
await createDefaultRoleConfig({
displayName: appDisplayName,
appDirectory: sourceFolderPath,
fileFolder: 'roles',
fileName: 'default-role.ts',
});
if (exampleOptions.includeExampleObject) {
await createExampleObject({
appDirectory: sourceFolderPath,
fileFolder: 'objects',
fileName: 'example-object.ts',
});
}
if (exampleOptions.includeExampleField) {
await createExampleField({
appDirectory: sourceFolderPath,
fileFolder: 'fields',
fileName: 'example-field.ts',
});
}
if (exampleOptions.includeExampleLogicFunction) {
await createDefaultFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'hello-world.ts',
});
}
if (exampleOptions.includeExampleFrontComponent) {
await createDefaultFrontComponent({
appDirectory: sourceFolderPath,
fileFolder: 'front-components',
fileName: 'hello-world.tsx',
});
}
if (exampleOptions.includeExampleView) {
await createExampleView({
appDirectory: sourceFolderPath,
fileFolder: 'views',
fileName: 'example-view.ts',
});
}
if (exampleOptions.includeExampleNavigationMenuItem) {
await createExampleNavigationMenuItem({
appDirectory: sourceFolderPath,
fileFolder: 'navigation-menu-items',
fileName: 'example-navigation-menu-item.ts',
});
}
if (exampleOptions.includeExampleSkill) {
await createExampleSkill({
appDirectory: sourceFolderPath,
fileFolder: 'skills',
fileName: 'example-skill.ts',
});
}
await createDefaultPreInstallFunction({
await createDefaultFrontComponent({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'pre-install.ts',
});
await createDefaultPostInstallFunction({
await createDefaultFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'post-install.ts',
});
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
appDirectory: sourceFolderPath,
fileName: 'application-config.ts',
});
};
@@ -147,7 +79,8 @@ generated
# dev
/dist/
.twenty
.twenty/*
!.twenty/output/
# production
/build
@@ -175,13 +108,9 @@ yarn-error.log*
const createDefaultRoleConfig = async ({
displayName,
appDirectory,
fileFolder,
fileName,
}: {
displayName: string;
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
@@ -201,18 +130,13 @@ export default defineRole({
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
await fs.writeFile(join(appDirectory, 'default.role.ts'), content);
};
const createDefaultFrontComponent = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
@@ -235,20 +159,19 @@ export default defineFrontComponent({
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
await fs.writeFile(
join(appDirectory, 'hello-world.front-component.tsx'),
content,
);
};
const createDefaultFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const triggerUniversalIdentifier = v4();
const content = `import { defineLogicFunction } from 'twenty-sdk';
@@ -256,261 +179,42 @@ const handler = async (): Promise<{ message: string }> => {
return { message: 'Hello, World!' };
};
// Logic function handler - rename and implement your logic
export default defineLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'hello-world-logic-function',
description: 'A simple logic function',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/hello-world-logic-function',
httpMethod: 'GET',
isAuthRequired: false,
},
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPreInstallFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPostInstallFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
};
export default definePostInstallLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExampleObject = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const objectUniversalIdentifier = v4();
const nameFieldUniversalIdentifier = v4();
const content = `import { defineObject, FieldType } from 'twenty-sdk';
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
'${objectUniversalIdentifier}';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'${nameFieldUniversalIdentifier}';
export default defineObject({
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'exampleItem',
namePlural: 'exampleItems',
labelSingular: 'Example item',
labelPlural: 'Example items',
description: 'A sample custom object',
icon: 'IconBox',
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
triggers: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the example item',
icon: 'IconAbc',
universalIdentifier: '${triggerUniversalIdentifier}',
type: 'route',
path: '/hello-world-logic-function',
httpMethod: 'GET',
isAuthRequired: false,
},
],
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExampleField = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineField, FieldType } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export default defineField({
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: '${universalIdentifier}',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
description: 'Priority level for the example item (1-10)',
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExampleView = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineView } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export default defineView({
universalIdentifier: '${universalIdentifier}',
name: 'example-view',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExampleNavigationMenuItem = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineNavigationMenuItem } from 'twenty-sdk';
export default defineNavigationMenuItem({
universalIdentifier: '${universalIdentifier}',
name: 'example-navigation-menu-item',
icon: 'IconList',
position: 0,
// Link to a view:
// viewUniversalIdentifier: '...',
// Or link to an object:
// targetObjectUniversalIdentifier: '...',
// Or link to an external URL:
// link: 'https://example.com',
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExampleSkill = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineSkill } from 'twenty-sdk';
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export default defineSkill({
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
name: 'example-skill',
label: 'Example Skill',
description: 'A sample skill for your application',
icon: 'IconBrain',
content: 'Add your skill instructions here. Skills provide context and capabilities to AI agents.',
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
await fs.writeFile(
join(appDirectory, 'hello-world.logic-function.ts'),
content,
);
};
const createApplicationConfig = async ({
displayName,
description,
appDirectory,
fileFolder,
fileName,
}: {
displayName: string;
description?: string;
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const content = `import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/default.role';
export default defineApplication({
universalIdentifier: '${v4()}',
@@ -520,8 +224,7 @@ export default defineApplication({
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
await fs.writeFile(join(appDirectory, 'application.config.ts'), content);
};
const createPackageJson = async ({
@@ -542,17 +245,29 @@ const createPackageJson = async ({
},
packageManager: 'yarn@4.9.2',
scripts: {
twenty: 'twenty',
'auth:login': 'twenty auth:login',
'auth:logout': 'twenty auth:logout',
'auth:status': 'twenty auth:status',
'auth:switch': 'twenty auth:switch',
'auth:list': 'twenty auth:list',
'app:dev': 'twenty app:dev',
'entity:add': 'twenty entity:add',
'app:generate': 'twenty app:generate',
'function:logs': 'twenty function:logs',
'function:execute': 'twenty function:execute',
'app:uninstall': 'twenty app:uninstall',
help: 'twenty help',
lint: 'eslint',
'lint:fix': 'eslint --fix',
},
dependencies: {},
dependencies: {
'twenty-sdk': '0.4.0',
},
devDependencies: {
'twenty-sdk': createTwentyAppPackageJson.version,
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
'@types/react': '^19.0.2',
react: '^19.0.2',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
},
@@ -6,13 +6,7 @@ const execPromise = promisify(exec);
export const install = async (root: string) => {
try {
await execPromise('corepack enable', { cwd: root });
} catch (error: any) {
console.warn(chalk.yellow('corepack enabled failed:'), error.stderr);
}
try {
await execPromise('yarn install', { cwd: root });
await execPromise('yarn', { cwd: root });
} catch (error: any) {
console.error(chalk.red('yarn install failed:'), error.stdout);
}
+2 -4
View File
@@ -11,10 +11,8 @@
"noEmit": true,
"types": ["jest", "node"],
"paths": {
"@/*": ["./src/*"],
"package.json": ["./package.json"]
},
"jsx": "react"
"@/*": ["./src/*"]
}
},
"include": [
"src/**/*.ts",
+7 -18
View File
@@ -4,7 +4,6 @@ import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import tsconfigPaths from 'vite-tsconfig-paths';
import packageJson from './package.json';
import type { PackageJson } from 'type-fest';
const moduleEntries = Object.keys((packageJson as any).exports || {})
.filter(
@@ -71,23 +70,13 @@ export default defineConfig(() => {
outDir: 'dist',
lib: { entry: entries, name: 'create-twenty-app' },
rollupOptions: {
external: (id: string) => {
if (/^node:/.test(id)) {
return true;
}
const builtins = ['path', 'fs', 'child_process', 'util'];
if (builtins.includes(id)) {
return true;
}
const deps = Object.keys(
(packageJson as PackageJson).dependencies || {},
);
return deps.some((dep) => id === dep || id.startsWith(dep + '/'));
},
external: [
...Object.keys((packageJson as any).dependencies || {}),
'path',
'fs',
'child_process',
'util',
],
output: [
{
format: 'es',
@@ -17,6 +17,7 @@
},
"scripts": {
"auth": "twenty auth login",
"generate": "twenty app generate",
"dev": "twenty app dev",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
@@ -17,6 +17,7 @@
},
"scripts": {
"auth": "twenty auth login",
"generate": "twenty app generate",
"dev": "twenty app dev",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
@@ -17,6 +17,7 @@
},
"scripts": {
"auth": "twenty auth login",
"generate": "twenty app generate",
"dev": "twenty app dev",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
@@ -11,6 +11,7 @@
"scripts": {
"create-entity": "twenty app add",
"dev": "twenty app dev",
"generate": "twenty app generate",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
"auth": "twenty auth login"
@@ -13,7 +13,7 @@ const config: ApplicationConfig = {
isSecret: false,
},
},
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
functionRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
};
export default config;
@@ -1,37 +1,2 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
.yarn/install-state.gz
.env
@@ -2,6 +2,25 @@
Used to manage billing and telemetry of self-hosted instances
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
## Install to your Twenty workspace
```bash
twenty auth login
twenty app sync
```
## Environment Variables
This application requires the following environment variables to be set:
- `TWENTY_API_URL`: The Twenty instance API URL where selfHostingUser records will be created
- `TWENTY_API_KEY`: API key for authentication (generate at `/settings/api-webhooks`)
## Features
### Telemetry Webhook
@@ -9,13 +9,27 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"auth:login": "twenty auth login",
"auth:logout": "twenty auth logout",
"auth:status": "twenty auth status",
"auth:switch": "twenty auth switch",
"auth:list": "twenty auth list",
"app:dev": "twenty app dev",
"app:sync": "twenty app sync",
"entity:add": "twenty entity add",
"app:generate": "twenty app generate",
"function:logs": "twenty function logs",
"function:execute": "twenty function execute",
"app:uninstall": "twenty app uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "0.3.1"
},
"devDependencies": {
"@types/node": "^24.7.2",
"twenty-sdk": "0.6.2"
"@types/node": "^24.7.2"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "a7070f46-3158-4b40-828f-8e6b1febc233"
@@ -0,0 +1,19 @@
import { defineApp } from 'twenty-sdk';
export default defineApp({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
description: 'Used to manage billing and telemetry of self-hosted instances',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
description: 'Twenty API key for creating selfHostingUser records',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
description: 'Twenty API URL (e.g., https://api.twenty.com)',
isSecret: false,
},
},
});
@@ -0,0 +1,18 @@
import { FieldType, defineObject } from 'twenty-sdk';
export default defineObject({
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
nameSingular: 'selfHostingUser',
namePlural: 'selfHostingUsers',
labelSingular: 'Self Hosting User',
labelPlural: 'Self Hosting Users',
fields: [
{
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
description: 'The email of the self hosting user',
universalIdentifier: 'a4b7892c-431a-4d44-973e-a5481652704f',
},
],
});
@@ -0,0 +1,132 @@
import { defineFunction } from 'twenty-sdk';
import { createClient } from '../../generated';
// TODO: import from twenty-sdk when 0.4.0 is deployed
type ServerlessFunctionEvent<TBody = object> = {
headers: Record<string, string | undefined>;
queryStringParameters: Record<string, string | undefined>;
pathParameters: Record<string, string | undefined>;
body: TBody | null;
isBase64Encoded: boolean;
requestContext: {
http: {
method: string;
path: string;
};
};
};
type TelemetryEventPayload = {
action: string;
timestamp: string;
version: string;
payload: {
userId: string | null;
workspaceId: string | null;
payload?: {
events?: Array<{
userId?: string;
userEmail?: string;
userFirstName?: string;
userLastName?: string;
locale?: string;
serverUrl?: string;
}>;
};
};
};
export const main = async (
params: ServerlessFunctionEvent<TelemetryEventPayload>,
): Promise<{ success: boolean; message: string; error?: string }> => {
try {
const { action, payload } = params.body || {};
if (action !== 'user_signup') {
return {
success: true,
message: `Event type '${action}' ignored`,
};
}
const userEmail =
payload?.payload?.events?.[0]?.userEmail ||
payload?.payload?.events?.[0]?.userId;
if (!userEmail) {
return {
success: false,
message: 'No email found in telemetry event',
error: 'Missing userEmail in payload',
};
}
if (
userEmail.toLowerCase().includes('example') ||
userEmail.toLowerCase().includes('test')
) {
return {
success: true,
message: `Email '${userEmail}' ignored (contains test/example data)`,
};
}
const client = createClient({
url: `${process.env.TWENTY_API_URL}/graphql`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
// Create or update selfHostingUser record
const result = await client.mutation({
createSelfHostingUser: {
__args: {
data: {
name:
payload?.payload?.events?.[0]?.userFirstName +
' ' +
payload?.payload?.events?.[0]?.userLastName,
email: {
primaryEmail: userEmail,
additionalEmails: null,
},
},
upsert: true,
},
id: true,
email: {
primaryEmail: true,
},
},
});
return {
success: true,
message: `Self hosting user created/updated: ${result.createSelfHostingUser?.id}`,
};
} catch (error) {
return {
success: false,
message: 'Failed to process telemetry event',
error: error instanceof Error ? error.message : String(error),
};
}
};
export default defineFunction({
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
name: 'telemetry-webhook',
timeoutSeconds: 5,
handler: main,
triggers: [
{
universalIdentifier: '7c8e3f5a-9b4c-4d1e-8f2a-1b3c4d5e6f7a',
type: 'route',
path: '/webhook/telemetry',
httpMethod: 'POST',
isAuthRequired: false,
},
],
});
@@ -1,10 +0,0 @@
import { defineApplication } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineApplication({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
description: 'Used to manage billing and telemetry of self-hosted instances',
defaultRoleUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.roles.defaultRole.universalIdentifier,
});
@@ -1,120 +0,0 @@
export const UNIVERSAL_IDENTIFIERS = {
objects: {
selfHostingUser: {
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
fields: {
name: { universalIdentifier: '682cccbf-9f37-4290-a94c-902c771f61e4' },
email: { universalIdentifier: 'a4b7892c-431a-4d44-973e-a5481652704f' },
personId: {
universalIdentifier: 'b453a43c-1512-48ca-8604-db750ad3ffb8',
},
domain: {
universalIdentifier: '1dfa7d4e-c8f5-4639-b58e-3392a8789f76',
},
userWorkspaceId: {
universalIdentifier: '297a7d6b-e407-4b2d-8c03-8964bc1b7805',
},
userId: {
universalIdentifier: '5c7ba3ce-1473-4e3d-8e7c-31816fcb87d8',
},
locale: {
universalIdentifier: '7b39df37-a22e-4f38-ae77-91cf3ee7c076',
},
serverUrl: {
universalIdentifier: 'f2516b77-2912-4cbb-8838-46ac5a5465d9',
},
serverId: {
universalIdentifier: 'e68a2b15-786d-4e9d-a74d-6d6d577ae721',
},
numberOfEmailsWithSameDomain: {
universalIdentifier: '0bf05db0-6771-4400-91ca-1579ec11e76e',
},
isEnriched: {
universalIdentifier: 'fefe9fd6-23ae-4046-b60b-64d17e9ff7ed',
},
triedToBeEnriched: {
universalIdentifier: 'd32c8cc3-8855-453d-bb7d-9c9c0b3f2128',
},
isPersonalEmail: {
universalIdentifier: 'f4568391-9474-4ed8-8cbb-e36d86e0f5f9',
},
isTwenty: {
universalIdentifier: 'b1acef1f-7c10-47a9-899e-aaca45b36e04',
},
personCity: {
universalIdentifier: 'ca733484-e595-4257-9ca9-9a7802fb8bcb',
},
personCountry: {
universalIdentifier: '18c06357-1b50-4d5b-82cf-1f71f286fbe4',
},
personJobFunction: {
universalIdentifier: '26e7e2c7-ea83-41e0-8c07-1fc2549a3fb4',
},
personJobTitle: {
universalIdentifier: '177908e9-1ca6-4762-9518-0df966d3e9fc',
},
personLinkedIn: {
universalIdentifier: '3515683f-7f9f-4b6d-9b16-614824d277b7',
},
personSeniority: {
universalIdentifier: '8b63855a-5915-4d6a-a6ed-ef7d8f8e5dd1',
},
companyAlexaRank: {
universalIdentifier: '7c61335b-cd4b-4eae-8b02-0db746913e36',
},
companyAnnualRevenue: {
universalIdentifier: 'a2367973-aa12-42c2-9577-fe868f61b83b',
},
companyAnnualRevenuePrinted: {
universalIdentifier: 'bc02b6af-8f48-4fde-920d-1fd3e2a8557b',
},
companyDescription: {
universalIdentifier: 'a9bb622e-56b6-42ba-8b03-17a47d707409',
},
companyEmployees: {
universalIdentifier: '8e1dbc58-d444-470f-b8fe-9eed8da4b59e',
},
companyFoundedYear: {
universalIdentifier: '3cf95527-5064-43ab-bf5e-421eb45fac5f',
},
companyFundingLatestStage: {
universalIdentifier: 'a7dcd92a-6811-490b-a8dd-fad1c19091a1',
},
companyFundingTotalAmount: {
universalIdentifier: '6fca8a11-b49a-4081-a7c9-9646f43ad7aa',
},
companyFundingTotalAmountPrinted: {
universalIdentifier: '0078f0f0-2262-4c74-aaf8-4061c6c8a1f3',
},
companyIndustries: {
universalIdentifier: '6b971b9c-6ef5-4497-989e-f9a7c72720cf',
},
companyIndustry: {
universalIdentifier: 'ab84e651-d35b-4e02-8d69-1740af3e22f7',
},
companyLinkedIn: {
universalIdentifier: '4c44b956-f880-434f-b4cd-854b82076e56',
},
companyName: {
universalIdentifier: '1a25412b-f9ce-4406-ac53-f20d1ab8c5ea',
},
companyTags: {
universalIdentifier: 'ceb64d0b-1203-4c6d-af00-39b668f5f891',
},
companyTech: {
universalIdentifier: '11dd57c3-06bb-4722-bb65-96d0a899ca91',
},
},
},
},
roles: {
defaultRole: {
universalIdentifier: '66972e19-9fdb-4336-87ce-442a17fd179c',
},
},
views: {
selfHostingUserView: {
universalIdentifier: 'e903f0ee-52cb-4537-aca8-8940e30b023d',
},
},
};
@@ -1,29 +0,0 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export const SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER =
'9507f244-fdea-47d5-a734-725d4dae43da';
export default defineField({
universalIdentifier: SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER,
name: 'selfHostingUsers',
label: 'Self hosting users',
description: 'Self hosting user related to the person',
type: FieldType.RELATION,
relationTargetFieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personId
.universalIdentifier,
relationTargetObjectMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.universalIdentifier,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
isNullable: true,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,94 +0,0 @@
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { SELF_HOSTING_USER_NAME_SINGULAR } from 'src/objects/selfHostingUser.object';
import { type SelfHostingUser } from 'twenty-sdk/generated/core';
import { CoreApiClient } from 'twenty-sdk/generated';
const handler = async (
params: DatabaseEventPayload<
| ObjectRecordCreateEvent<SelfHostingUser>
| ObjectRecordUpdateEvent<SelfHostingUser>
>,
) => {
const [object, action] = params.name.split('.');
if (object !== SELF_HOSTING_USER_NAME_SINGULAR) {
return;
}
if (!['created', 'updated'].includes(action)) {
return;
}
const email = params.properties.after.email?.primaryEmail;
if (!email) {
return;
}
const client = new CoreApiClient();
const { people } = await client.query({
people: {
edges: { node: { id: true } },
__args: {
filter: {
emails: {
primaryEmail: { eq: email },
},
},
},
},
});
let personId = people?.edges[0]?.node?.id;
if (!personId) {
const { createPerson } = await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: params.properties.after.name?.firstName,
lastName: params.properties.after.name?.lastName,
},
emails: {
primaryEmail: email,
},
},
},
id: true,
},
});
personId = createPerson?.id;
}
await client.mutation({
updateSelfHostingUser: {
__args: {
id: params.properties.after.id,
data: {
personId,
},
},
id: true,
},
});
};
export default defineLogicFunction({
universalIdentifier: '87f0293a-997a-4c7b-85e2-e77462ccf0c5',
name: 'match-telemetry-event-with-people',
description:
'Matches self hosting users with existing people based on email address',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: {
eventName: `${SELF_HOSTING_USER_NAME_SINGULAR}.*`,
},
});
@@ -1,136 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import { type TelemetryEvent } from 'src/logic-functions/types/telemetry-event.type';
export const main = async (
params: RoutePayload<TelemetryEvent>,
): Promise<{
success: boolean;
message: string;
error?: string;
}> => {
try {
const {
action,
workspaceId,
userWorkspaceId,
userId,
userEmail,
userFirstName,
userLastName,
locale,
serverUrl,
serverId,
} = params.body || {};
if (action !== 'user_signup') {
return {
success: true,
message: `Event type '${action}' ignored`,
};
}
if (!userEmail) {
return {
success: true,
message: 'No email found in telemetry event',
};
}
if (
userEmail.toLowerCase().includes('example') ||
userEmail.toLowerCase().includes('test')
) {
return {
success: true,
message: `Email '${userEmail}' ignored (contains test/example data)`,
};
}
const client = new CoreApiClient();
let existingSelfHostingUserId: string | undefined = undefined;
try {
const { selfHostingUser: existingSelfHostingUser } = await client.query({
selfHostingUser: {
__args: {
filter: {
email: { primaryEmail: { eq: userEmail } },
},
},
id: true,
},
});
existingSelfHostingUserId = existingSelfHostingUser?.id;
} catch {
//
}
if (existingSelfHostingUserId) {
await client.mutation({
updateSelfHostingUser: {
__args: {
id: existingSelfHostingUserId,
data: {
name: { firstName: userFirstName, lastName: userLastName },
email: { primaryEmail: userEmail, additionalEmails: null },
userWorkspaceId,
userId,
locale,
serverUrl,
serverId,
},
},
id: true,
},
});
return {
success: true,
message: `Self hosting user ${existingSelfHostingUserId} updated`,
};
}
const { createSelfHostingUser } = await client.mutation({
createSelfHostingUser: {
__args: {
data: {
name: { firstName: userFirstName, lastName: userLastName },
email: { primaryEmail: userEmail, additionalEmails: null },
workspaceId,
userWorkspaceId,
userId,
locale,
serverUrl,
serverId,
},
},
id: true,
},
});
return {
success: true,
message: `Self hosting user ${createSelfHostingUser?.id} created`,
};
} catch (error) {
return {
success: false,
message: 'Failed to process telemetry event',
error: error instanceof Error ? error.message : String(error),
};
}
};
export default defineLogicFunction({
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
name: 'telemetry-webhook',
timeoutSeconds: 10,
handler: main,
httpRouteTriggerSettings: {
path: '/webhook/telemetry',
httpMethod: 'POST',
isAuthRequired: false,
},
});
@@ -1,12 +0,0 @@
export type TelemetryEvent = {
action: string;
workspaceId?: string;
userWorkspaceId?: string;
userId: string;
userEmail?: string;
userFirstName?: string;
userLastName?: string;
locale?: string;
serverUrl: string;
serverId: string;
};
@@ -1,11 +0,0 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineNavigationMenuItem({
universalIdentifier: 'fe3aaca4-9eda-4565-b215-5d268fbf8164',
name: 'Self host user',
icon: 'IconList',
position: 1,
viewUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.views.selfHostingUserView.universalIdentifier,
});
@@ -1,354 +0,0 @@
import {
defineObject,
FieldType,
RelationType,
OnDeleteAction,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
import { SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER } from 'src/fields/self-hosting-user-id';
export const SELF_HOSTING_USER_NAME_SINGULAR = 'selfHostingUser';
export default defineObject({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.universalIdentifier,
nameSingular: SELF_HOSTING_USER_NAME_SINGULAR,
namePlural: 'selfHostingUsers',
labelSingular: 'Self Hosting User',
labelPlural: 'Self Hosting Users',
fields: [
{
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personId
.universalIdentifier,
name: 'person',
label: 'Person',
description: 'Person matching with the self hosting user',
type: FieldType.RELATION,
relationTargetFieldMetadataUniversalIdentifier:
SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
isNullable: true,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'personId',
},
},
{
type: FieldType.FULL_NAME,
name: 'name',
label: 'Name',
description: 'Name of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.name
.universalIdentifier,
},
{
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
description: 'The email of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.email
.universalIdentifier,
},
{
type: FieldType.LINKS,
name: 'domain',
label: 'Domain',
description:
'Domain extracted from the email address (e.g. domain.com / https://domain.com/)',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.domain
.universalIdentifier,
},
{
type: FieldType.UUID,
name: 'userWorkspaceId',
label: 'User workspace Id',
description: 'User workspace id of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userWorkspaceId
.universalIdentifier,
},
{
type: FieldType.UUID,
name: 'userId',
label: 'User Id',
description: 'User id of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userId
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'locale',
label: 'Locale',
description: 'Locale of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.locale
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'serverUrl',
label: 'Server url',
description: 'Server url of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverUrl
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'serverId',
label: 'Server id',
description: 'Server id of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverId
.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'numberOfEmailsWithSameDomain',
label: 'Number of Emails with Same Domain',
description:
'Aggregated count of self hosting users sharing the same business domain',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.numberOfEmailsWithSameDomain.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'isEnriched',
label: 'Is Enriched',
description: 'Whether the record has been enriched',
defaultValue: false,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isEnriched
.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'triedToBeEnriched',
label: 'Tried to Be Enriched',
description: 'Whether an enrichment attempt has been made',
defaultValue: false,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.triedToBeEnriched
.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'isPersonalEmail',
label: 'Is Personal Email',
description: 'Whether the email is a personal email address',
defaultValue: true,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isPersonalEmail
.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'isTwenty',
label: 'Is Twenty',
description: 'Whether the user is from Twenty',
defaultValue: false,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isTwenty
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personCity',
label: 'Person City',
description: 'City of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCity
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personCountry',
label: 'Person Country',
description: 'Country of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCountry
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personJobFunction',
label: 'Person Job Function',
description: 'Job function of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobFunction
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personJobTitle',
label: 'Person Job Title',
description: 'Job title of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobTitle
.universalIdentifier,
},
{
type: FieldType.LINKS,
name: 'personLinkedIn',
label: 'Person LinkedIn',
description: 'LinkedIn profile of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personLinkedIn
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personSeniority',
label: 'Person Seniority',
description: 'Seniority level of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personSeniority
.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'companyAlexaRank',
label: 'Company Alexa Rank',
description: 'Alexa rank of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyAlexaRank
.universalIdentifier,
},
{
type: FieldType.CURRENCY,
name: 'companyAnnualRevenue',
label: 'Company Annual Revenue',
description: 'Annual revenue of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenue.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyAnnualRevenuePrinted',
label: 'Company Annual Revenue Printed',
description: 'Formatted annual revenue of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenuePrinted.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyDescription',
label: 'Company Description',
description: 'Description of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyDescription
.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'companyEmployees',
label: 'Company Employees',
description: 'Number of employees at the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyEmployees
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyFoundedYear',
label: 'Company Founded Year',
description: 'Year the company was founded',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyFoundedYear
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyFundingLatestStage',
label: 'Company Funding Latest Stage',
description: 'Latest funding stage of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingLatestStage.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'companyFundingTotalAmount',
label: 'Company Funding Total Amount',
description: 'Total funding amount of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmount.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyFundingTotalAmountPrinted',
label: 'Company Funding Total Amount Printed',
description: 'Formatted total funding amount of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmountPrinted.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyIndustries',
label: 'Company Industries',
description: 'Industries the company operates in',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustries
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyIndustry',
label: 'Company Industry',
description: 'Primary industry of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustry
.universalIdentifier,
},
{
type: FieldType.LINKS,
name: 'companyLinkedIn',
label: 'Company LinkedIn',
description: 'LinkedIn page of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyLinkedIn
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyName',
label: 'Company Name',
description: 'Name of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyName
.universalIdentifier,
},
{
type: FieldType.ARRAY,
name: 'companyTags',
label: 'Company Tags',
description: 'Tags associated with the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTags
.universalIdentifier,
},
{
type: FieldType.ARRAY,
name: 'companyTech',
label: 'Company Tech',
description: 'Technologies used by the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTech
.universalIdentifier,
},
],
});
@@ -1,13 +0,0 @@
import { defineRole } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineRole({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.roles.defaultRole.universalIdentifier,
label: 'default role',
description: 'Add a description for your role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -1,308 +0,0 @@
import { defineView } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineView({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.views.selfHostingUserView.universalIdentifier,
name: 'Self hosting users',
objectUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.universalIdentifier,
icon: 'IconList',
position: 0,
fields: [
{
universalIdentifier: '243a2401-cd13-440c-8dcd-649e26df36bc',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.name
.universalIdentifier,
position: 0,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'dfa75ef8-d40d-416f-9f1c-3e86edfa9fce',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.email
.universalIdentifier,
position: 1,
isVisible: true,
size: 150,
},
{
universalIdentifier: '15cc9215-eb48-4487-a92e-a25d8e99702f',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.domain
.universalIdentifier,
position: 2,
isVisible: true,
size: 200,
},
{
universalIdentifier: '0f9e4f63-3664-443a-9f06-8a6cc04c1d90',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personId
.universalIdentifier,
position: 2.1,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'dcf88ae8-e71d-452f-b51e-d88cbc6dd273',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userWorkspaceId
.universalIdentifier,
position: 3,
isVisible: true,
},
{
universalIdentifier: 'aad70516-936b-41d1-b6c6-961a22299761',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userId
.universalIdentifier,
position: 4,
isVisible: true,
},
{
universalIdentifier: '8c210eb0-bdda-476e-9f98-42f909872f2a',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.locale
.universalIdentifier,
position: 5,
isVisible: true,
},
{
universalIdentifier: '367abe85-11c4-440f-80a2-663edd6b4231',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverUrl
.universalIdentifier,
position: 6,
isVisible: true,
},
{
universalIdentifier: '32c199d6-ebf3-434b-81b4-e2b59a0518b7',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverId
.universalIdentifier,
position: 6.1,
isVisible: true,
},
{
universalIdentifier: '924ee786-ab93-44be-9d21-941ff9ffe1ac',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.numberOfEmailsWithSameDomain.universalIdentifier,
position: 7,
isVisible: true,
},
{
universalIdentifier: '2feadf3d-e251-4356-add8-7fa70dea5401',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isEnriched
.universalIdentifier,
position: 8,
isVisible: true,
},
{
universalIdentifier: 'de252ae6-c723-4bf7-96cf-d93f5a539f36',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.triedToBeEnriched
.universalIdentifier,
position: 9,
isVisible: true,
},
{
universalIdentifier: 'b121e8e6-b3eb-4f6c-b67e-c7c6d19e1bc5',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isPersonalEmail
.universalIdentifier,
position: 10,
isVisible: true,
},
{
universalIdentifier: '0ada0bcc-8d6b-4df6-bcc1-78ba14cb04e6',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isTwenty
.universalIdentifier,
position: 11,
isVisible: true,
},
{
universalIdentifier: 'ec7c8d51-ea63-41bd-9eb1-995835b94218',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCity
.universalIdentifier,
position: 12,
isVisible: true,
},
{
universalIdentifier: '7522dd84-0d23-48e7-85dd-f0a8d9e275f8',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCountry
.universalIdentifier,
position: 13,
isVisible: true,
},
{
universalIdentifier: '54191cb9-4d5c-466e-affb-d9ba4adeff87',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobFunction
.universalIdentifier,
position: 14,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'ace75fc7-fb20-4e53-a9a2-6a7529befaf0',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobTitle
.universalIdentifier,
position: 15,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'a0b42d61-4553-42eb-aca4-327b9bf9f30e',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personLinkedIn
.universalIdentifier,
position: 16,
isVisible: true,
},
{
universalIdentifier: '74bc7dd2-fe53-4ff4-8778-2768f3439571',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personSeniority
.universalIdentifier,
position: 17,
isVisible: true,
size: 180,
},
{
universalIdentifier: '61b34f41-8d56-472d-ab1e-414703c6ca12',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyAlexaRank
.universalIdentifier,
position: 18,
isVisible: true,
},
{
universalIdentifier: '5bb7d36b-6a73-4832-b41e-f67130a4708f',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenue.universalIdentifier,
position: 19,
isVisible: true,
},
{
universalIdentifier: 'ae6f23ce-006c-41dd-82a1-e9fe7b65bce3',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenuePrinted.universalIdentifier,
position: 20,
isVisible: true,
size: 250,
},
{
universalIdentifier: 'dd2a4728-a743-43bb-b096-9e7bd5125e56',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyDescription
.universalIdentifier,
position: 21,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'ecca02c9-db2e-41e2-b571-b5db75054b56',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyEmployees
.universalIdentifier,
position: 22,
isVisible: true,
},
{
universalIdentifier: '2e76775b-f8b8-4184-8cd3-72d2b93edaa2',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyFoundedYear
.universalIdentifier,
position: 23,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'a7eb002c-6f0c-48ba-a9eb-247c498ad9bd',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingLatestStage.universalIdentifier,
position: 24,
isVisible: true,
size: 240,
},
{
universalIdentifier: '61be97f6-20da-4b2b-861d-32345e0f9953',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmount.universalIdentifier,
position: 25,
isVisible: true,
},
{
universalIdentifier: '55720810-3120-4e76-bcf2-2da9517edbbc',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmountPrinted.universalIdentifier,
position: 26,
isVisible: true,
size: 280,
},
{
universalIdentifier: '01e31752-cbc1-499a-8ecf-504dd402d7e2',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustries
.universalIdentifier,
position: 27,
isVisible: true,
size: 200,
},
{
universalIdentifier: '2e1c8b8b-469b-483e-8348-1fe3d1764e17',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustry
.universalIdentifier,
position: 28,
isVisible: true,
size: 180,
},
{
universalIdentifier: '976cc8ae-6cf8-4c30-8da4-5bf61e799893',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyLinkedIn
.universalIdentifier,
position: 29,
isVisible: true,
},
{
universalIdentifier: '5f0776b3-2849-4b9b-82f0-baa38c6d889d',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyName
.universalIdentifier,
position: 30,
isVisible: true,
},
{
universalIdentifier: '86f0397a-2924-4e5c-a610-3c9ad7bb4923',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTags
.universalIdentifier,
position: 31,
isVisible: true,
},
{
universalIdentifier: '562084f4-1242-4e60-868b-1d9b268a35b0',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTech
.universalIdentifier,
position: 32,
isVisible: true,
},
],
});
@@ -5,14 +5,13 @@
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
@@ -27,5 +26,10 @@
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
File diff suppressed because it is too large Load Diff
@@ -17,10 +17,7 @@ setup_and_migrate_db() {
yarn database:migrate:prod
fi
yarn command:prod cache:flush
yarn command:prod upgrade
yarn command:prod cache:flush
echo "Successfully migrated DB!"
}
@@ -7,9 +7,9 @@ This document outlines the best practices you should follow when working on the
## State management
React and Jotai handle state management in the codebase.
React and Recoil handle state management in the codebase.
### Use Jotai atoms to store state
### Use `useRecoilState` to store state
It's good practice to create as many atoms as you need to store your state.
@@ -20,16 +20,13 @@ It's better to use extra atoms than trying to be too concise with props drilling
</Warning>
```tsx
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
export const myAtomState = createAtomState<string>({
export const myAtomState = atom({
key: 'myAtomState',
defaultValue: 'default value',
default: 'default value',
});
export const MyComponent = () => {
const [myAtom, setMyAtom] = useAtomState(myAtomState);
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
return (
<div>
@@ -46,7 +43,7 @@ export const MyComponent = () => {
Avoid using `useRef` to store state.
If you want to store state, you should use `useState` or Jotai atoms with `useAtomState`.
If you want to store state, you should use `useState` or `useRecoilState`.
See [how to manage re-renders](#managing-re-renders) if you feel like you need `useRef` to prevent some re-renders from happening.
@@ -86,8 +83,8 @@ You can apply the same for data fetching logic, with Apollo hooks.
// ❌ Bad, will cause re-renders even if data is not changing,
// because useEffect needs to be re-evaluated
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -99,7 +96,9 @@ export const PageComponent = () => {
};
export const App = () => (
<PageComponent />
<RecoilRoot>
<PageComponent />
</RecoilRoot>
);
```
@@ -107,14 +106,14 @@ export const App = () => (
// ✅ Good, will not cause re-renders if data is not changing,
// because useEffect is re-evaluated in another sibling component
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [data, setData] = useRecoilState(dataState);
return <div>{data}</div>;
};
export const PageData = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -126,16 +125,16 @@ export const PageData = () => {
};
export const App = () => (
<>
<RecoilRoot>
<PageData />
<PageComponent />
</>
</RecoilRoot>
);
```
### Use atom family states and selectors
### Use recoil family states and recoil family selectors
Atom family states and selectors are a great way to avoid re-renders.
Recoil family states and selectors are a great way to avoid re-renders.
They are useful when you need to store a list of items.
@@ -83,9 +83,9 @@ See [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) for more de
### States
Contains the state management logic. [Jotai](https://jotai.org) handles this.
Contains the state management logic. [RecoilJS](https://recoiljs.org) handles this.
- Selectors: Derived atoms (using `createAtomSelector`) compute values from other atoms and are automatically memoized.
- Selectors: See [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) for more details.
React's built-in state management still handles state within a component.
@@ -52,7 +52,7 @@ The project has a clean and simple stack, with minimal boilerplate code.
- [React](https://react.dev/)
- [Apollo](https://www.apollographql.com/docs/)
- [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
- [Jotai](https://jotai.org/)
- [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
- [TypeScript](https://www.typescriptlang.org/)
**Testing**
@@ -76,7 +76,7 @@ To avoid unnecessary [re-renders](/developers/contribute/capabilities/frontend-d
### State Management
[Jotai](https://jotai.org/) handles state management.
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) handles state management.
See [best practices](/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) for more information on state management.
@@ -159,7 +159,7 @@ export enum PageHotkeyScope {
}
```
Internally, the currently selected scope is stored in a Jotai atom that is shared across the application :
Internally, the currently selected scope is stored in a Recoil state that is shared across the application :
```tsx
export const currentHotkeyScopeState = createState<HotkeyScope>({
@@ -168,10 +168,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
});
```
But this atom should never be handled manually ! We'll see how to use it in the next section.
But this Recoil state should never be handled manually ! We'll see how to use it in the next section.
## How is it working internally?
We made a thin wrapper on top of [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) that makes it more performant and avoids unnecessary re-renders.
We also create a Jotai atom to handle the hotkey scope state and make it available everywhere in the application.
We also create a Recoil state to handle the hotkey scope state and make it available everywhere in the application.
@@ -14,9 +14,11 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf
**What you can do today:**
- Define custom objects and fields as code (managed data model)
- Build logic functions with custom triggers
- Define skills for AI agents
- Deploy the same app across multiple workspaces
**Coming soon:**
- Custom UI layouts and components
## Prerequisites
- Node.js 24+ and Yarn 4
@@ -27,50 +29,41 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf
Create a new app using the official scaffolder, then authenticate and start developing:
```bash filename="Terminal"
# Scaffold a new app (includes all examples by default)
# Scaffold a new app
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# If you don't use yarn@4
corepack enable
yarn install
# Authenticate using your API key (you'll be prompted)
yarn auth:login
# Start dev mode: automatically syncs local changes to your workspace
yarn twenty app:dev
```
The scaffolder supports three modes for controlling which example files are included:
```bash filename="Terminal"
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill)
npx create-twenty-app@latest my-app
# Minimal: only core files (application-config.ts and default-role.ts)
npx create-twenty-app@latest my-app --minimal
# Interactive: select which examples to include
npx create-twenty-app@latest my-app --interactive
yarn app:dev
```
From here you can:
```bash filename="Terminal"
# Add a new entity to your application (guided)
yarn twenty entity:add
yarn entity:add
# Generate a typed Twenty client and workspace entity types
yarn app:generate
# Watch your application's function logs
yarn twenty function:logs
yarn function:logs
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Execute the pre-install function
yarn twenty function:execute --preInstall
# Execute the post-install function
yarn twenty function:execute --postInstall
yarn function:execute -n my-function -p '{"name": "test"}'
# Uninstall the application from the current workspace
yarn twenty app:uninstall
yarn app:uninstall
# Display commands' help
yarn twenty help
yarn help
```
See also: the CLI reference pages for [create-twenty-app](https://www.npmjs.com/package/create-twenty-app) and [twenty-sdk CLI](https://www.npmjs.com/package/twenty-sdk).
@@ -82,9 +75,9 @@ When you run `npx create-twenty-app@latest my-twenty-app`, the scaffolder:
- Copies a minimal base application into `my-twenty-app/`
- Adds a local `twenty-sdk` dependency and Yarn 4 configuration
- Creates config files and scripts wired to the `twenty` CLI
- Generates core files (application config, default function role, pre-install and post-install functions) plus example files based on the scaffolding mode
- Generates a default application config and a default function role
A freshly scaffolded app with the default `--exhaustive` mode looks like this:
A freshly scaffolded app looks like this:
```text filename="my-twenty-app/"
my-twenty-app/
@@ -100,81 +93,87 @@ my-twenty-app/
README.md
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # Required - main application configuration
├── roles/
└── default-role.ts # Default role for logic functions
├── objects/
│ └── example-object.ts # Example custom object definition
├── fields/
│ └── example-field.ts # Example standalone field definition
├── logic-functions/
│ ├── hello-world.ts # Example logic function
│ ├── pre-install.ts # Pre-install logic function
│ └── post-install.ts # Post-install logic function
├── front-components/
│ └── hello-world.tsx # Example front component
├── views/
│ └── example-view.ts # Example saved view definition
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # Example sidebar navigation link
└── skills/
└── example-skill.ts # Example AI agent skill definition
application.config.ts # Required - main application configuration
default-function.role.ts # Default role for serverless functions
hello-world.function.ts # Example serverless function
hello-world.front-component.tsx # Example front component
// your entities (*.object.ts, *.function.ts, *.front-component.tsx, *.role.ts)
```
With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts`, and `logic-functions/post-install.ts`). With `--interactive`, you choose which example files to include.
### Convention-over-configuration
Applications use a **convention-over-configuration** approach where entities are detected by their file suffix. This allows flexible organization within the `src/app/` folder:
| File suffix | Entity type |
|-------------|-------------|
| `*.object.ts` | Custom object definitions |
| `*.function.ts` | Serverless function definitions |
| `*.front-component.tsx` | Front component definitions |
| `*.role.ts` | Role definitions |
### Supported folder organizations
You can organize your entities in any of these patterns:
**Traditional (by type):**
```text
src/
├── application.config.ts
├── objects/
│ └── postCard.object.ts
├── functions/
│ └── createPostCard.function.ts
├── components/
│ └── card.front-component.tsx
└── roles/
└── admin.role.ts
```
**Feature-based:**
```text
src/
├── application.config.ts
└── post-card/
├── postCard.object.ts
├── createPostCard.function.ts
├── card.front-component.tsx
└── postCardAdmin.role.ts
```
**Flat:**
```text
src/
├── application.config.ts
├── postCard.object.ts
├── createPostCard.function.ts
├── card.front-component.tsx
└── admin.role.ts
```
At a high level:
- **package.json**: Declares the app name, version, engines (Node 24+, Yarn 4), and adds `twenty-sdk` plus a `twenty` script that delegates to the local `twenty` CLI. Run `yarn twenty help` to list all available commands.
- **package.json**: Declares the app name, version, engines (Node 24+, Yarn 4), and adds `twenty-sdk` plus scripts like `app:dev`, `app:generate`, `entity:add`, `function:logs`, `function:execute`, `app:uninstall`, and `auth:login` that delegate to the local `twenty` CLI.
- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `generated/` (typed client), `dist/`, `build/`, coverage folders, log files, and `.env*` files.
- **yarn.lock**, **.yarnrc.yml**, **.yarn/**: Lock and configure the Yarn 4 toolchain used by the project.
- **.nvmrc**: Pins the Node.js version expected by the project.
- **eslint.config.mjs** and **tsconfig.json**: Provide linting and TypeScript configuration for your app's TypeScript sources.
- **README.md**: A short README in the app root with basic instructions.
- **public/**: A folder for storing public assets (images, fonts, static files) that will be served with your application. Files placed here are uploaded during sync and accessible at runtime.
- **src/**: The main place where you define your application-as-code
### Entity detection
The SDK detects entities by parsing your TypeScript files for **`export default define<Entity>({...})`** calls. Each entity type has a corresponding helper function exported from `twenty-sdk`:
| Helper function | Entity type |
|-----------------|-------------|
| `defineObject()` | Custom object definitions |
| `defineLogicFunction()` | Logic function definitions |
| `definePreInstallLogicFunction()` | Pre-install logic function (runs before installation) |
| `definePostInstallLogicFunction()` | Post-install logic function (runs after installation) |
| `defineFrontComponent()` | Front component definitions |
| `defineRole()` | Role definitions |
| `defineField()` | Field extensions for existing objects |
| `defineView()` | Saved view definitions |
| `defineNavigationMenuItem()` | Navigation menu item definitions |
| `defineSkill()` | AI agent skill definitions |
<Note>
**File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define<Entity>({...})` pattern. You can organize your files and folders however you like. Grouping by entity type (e.g., `logic-functions/`, `roles/`) is just a convention for code organization, not a requirement.
</Note>
Example of a detected entity:
```typescript
// This file can be named anything and placed anywhere in src/
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: '...',
nameSingular: 'postCard',
// ... rest of config
});
```
- **src/**: The main place where you define your application-as-code:
- `application.config.ts`: Global configuration for your app (metadata and runtime wiring). See "Application config" below.
- `*.role.ts`: Role definitions used by your logic functions. See "Default function role" below.
- `*.object.ts`: Custom object definitions.
- `*.function.ts`: Logic function definitions.
- `*.front-component.tsx`: Front component definitions.
Later commands will add more files and folders:
- `yarn twenty app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
- `yarn app:generate` will create a `generated/` folder (typed Twenty client + workspace types).
- `yarn entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, or roles.
## Authentication
The first time you run `yarn twenty auth:login`, you'll be prompted for:
The first time you run `yarn auth:login`, you'll be prompted for:
- API URL (defaults to http://localhost:3000 or your current workspace profile)
- API key
@@ -185,25 +184,25 @@ Your credentials are stored per-user in `~/.twenty/config.json`. You can maintai
```bash filename="Terminal"
# Login interactively (recommended)
yarn twenty auth:login
yarn auth:login
# Login to a specific workspace profile
yarn twenty auth:login --workspace my-custom-workspace
yarn auth:login --workspace my-custom-workspace
# List all configured workspaces
yarn twenty auth:list
yarn auth:list
# Switch the default workspace (interactive)
yarn twenty auth:switch
yarn auth:switch
# Switch to a specific workspace
yarn twenty auth:switch production
yarn auth:switch production
# Check current authentication status
yarn twenty auth:status
yarn auth:status
```
Once you've switched workspaces with `yarn twenty auth:switch`, all subsequent commands will use that workspace by default. You can still override it temporarily with `--workspace <name>`.
Once you've switched workspaces with `auth:switch`, all subsequent commands will use that workspace by default. You can still override it temporarily with `--workspace <name>`.
## Use the SDK resources (types & config)
@@ -211,23 +210,16 @@ The twenty-sdk provides typed building blocks and helper functions you use insid
### Helper functions
The SDK provides helper functions for defining your app entities. As described in [Entity detection](#entity-detection), you must use `export default define<Entity>({...})` for your entities to be detected:
The SDK provides four helper functions with built-in validation for defining your app entities:
| Function | Purpose |
|----------|---------|
| `defineApplication()` | Configure application metadata (required, one per app) |
| `defineApplication()` | Configure application metadata |
| `defineObject()` | Define custom objects with fields |
| `defineLogicFunction()` | Define logic functions with handlers |
| `definePreInstallLogicFunction()` | Define a pre-install logic function (one per app) |
| `definePostInstallLogicFunction()` | Define a post-install logic function (one per app) |
| `defineFrontComponent()` | Define front components for custom UI |
| `defineFunction()` | Define logic functions with handlers |
| `defineRole()` | Configure role permissions and object access |
| `defineField()` | Extend existing objects with additional fields |
| `defineView()` | Define saved views for objects |
| `defineNavigationMenuItem()` | Define sidebar navigation links |
| `defineSkill()` | Define AI agent skills |
These functions validate your configuration at build time and provide IDE autocompletion and type safety.
These functions validate your configuration at runtime and provide better IDE autocompletion and type safety.
### Defining objects
@@ -308,33 +300,27 @@ Key points:
- The `universalIdentifier` must be unique and stable across deployments.
- Each field requires a `name`, `type`, `label`, and its own stable `universalIdentifier`.
- The `fields` array is optional — you can define objects without custom fields.
- You can scaffold new objects using `yarn twenty entity:add`, which guides you through naming, fields, and relationships.
- You can scaffold new objects using `yarn entity:add`, which guides you through naming, fields, and relationships.
<Note>
**Base fields are created automatically.** When you define a custom object, Twenty automatically adds standard fields
such as `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` and `deletedAt`.
You don't need to define these in your `fields` array — only add your custom fields.
You can override default fields by defining a field with the same name in your `fields` array,
but this is not recommended.
**Base fields are created automatically.** When you define a custom object, Twenty automatically adds standard fields such as `name`, `createdAt`, `updatedAt`, `createdBy`, `position`, and `deletedAt`. You don't need to define these in your `fields` array — only add your custom fields.
</Note>
### Application config (application-config.ts)
### Application config (application.config.ts)
Every app has a single `application-config.ts` file that describes:
Every app has a single `application.config.ts` file that describes:
- **Who the app is**: identifiers, display name, and description.
- **How its functions run**: which role they use for permissions.
- **(Optional) variables**: keyvalue pairs exposed to your functions as environment variables.
- **(Optional) pre-install function**: a logic function that runs before the app is installed.
- **(Optional) post-install function**: a logic function that runs after the app is installed.
Use `defineApplication()` to define your application configuration:
```typescript
// src/application-config.ts
// src/app/application.config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -349,19 +335,18 @@ export default defineApplication({
isSecret: false,
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
roleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
```
Notes:
- `universalIdentifier` fields are deterministic IDs you own; generate them once and keep them stable across syncs.
- `applicationVariables` become environment variables for your functions (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`).
- `defaultRoleUniversalIdentifier` must match the role file (see below).
- Pre-install and post-install functions are automatically detected during the manifest build. See [Pre-install functions](#pre-install-functions) and [Post-install functions](#post-install-functions).
- `roleUniversalIdentifier` must match the role you define in your `*.role.ts` file (see below).
#### Roles and permissions
Applications can define roles that encapsulate permissions on your workspace's objects and actions. The field `defaultRoleUniversalIdentifier` in `application-config.ts` designates the default role used by your app's logic functions.
Applications can define roles that encapsulate permissions on your workspace's objects and actions. The field `roleUniversalIdentifier` in `application.config.ts` designates the default role used by your app's logic functions.
- The runtime API key injected as `TWENTY_API_KEY` is derived from this default function role.
- The typed client will be restricted to the permissions granted to that role.
@@ -372,7 +357,7 @@ Applications can define roles that encapsulate permissions on your workspace's o
When you scaffold a new app, the CLI also creates a default role file. Use `defineRole()` to define roles with built-in validation:
```typescript
// src/roles/default-role.ts
// src/app/default-function.role.ts
import { defineRole, PermissionFlag } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
@@ -411,10 +396,10 @@ export default defineRole({
});
```
The `universalIdentifier` of this role is then referenced in `application-config.ts` as `defaultRoleUniversalIdentifier`. In other words:
The `universalIdentifier` of this role is then referenced in `application.config.ts` as `roleUniversalIdentifier`. In other words:
- **\*.role.ts** defines what the default function role can do.
- **application-config.ts** points to that role so your functions inherit its permissions.
- **application.config.ts** points to that role so your functions inherit its permissions.
Notes:
- Start from the scaffolded role, then progressively restrict it following leastprivilege.
@@ -424,16 +409,16 @@ Notes:
### Logic function config and entrypoint
Each function file uses `defineLogicFunction()` to export a configuration with a handler and optional triggers.
Each function file uses `defineFunction()` to export a configuration with a handler and optional triggers. Use the `*.function.ts` file suffix for automatic detection.
```typescript
// src/app/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
// src/app/createPostCard.function.ts
import { defineFunction } from 'twenty-sdk';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
import Twenty, { type Person } from '~/generated';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const client = new Twenty(); // generated typed client
const name = 'name' in params.queryStringParameters
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
@@ -448,7 +433,7 @@ const handler = async (params: RoutePayload) => {
return result;
};
export default defineLogicFunction({
export default defineFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
@@ -490,80 +475,6 @@ Notes:
- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions.
- You can mix multiple trigger types in a single function.
### Pre-install functions
A pre-install function is a logic function that runs automatically before your app is installed on a workspace. This is useful for validation tasks, prerequisite checks, or preparing workspace state before the main installation proceeds.
When you scaffold a new app with `create-twenty-app`, a pre-install function is generated for you at `src/logic-functions/pre-install.ts`:
```typescript
// src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: '<generated-uuid>',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
```
You can also manually execute the pre-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --preInstall
```
Key points:
- Pre-install functions use `definePreInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs).
- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected.
- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks.
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `function:execute --preInstall`.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
};
export default definePostInstallLogicFunction({
universalIdentifier: '<generated-uuid>',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
Key points:
- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs).
- Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### Route trigger payload
<Warning>
@@ -591,7 +502,7 @@ const handler = async (event: RoutePayload) => {
When a route trigger invokes your logic function, it receives a `RoutePayload` object that follows the AWS HTTP API v2 format. Import the type from `twenty-sdk`:
```typescript
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
import { defineFunction, type RoutePayload } from 'twenty-sdk';
const handler = async (event: RoutePayload) => {
// Access request data
@@ -621,7 +532,7 @@ The `RoutePayload` type has the following structure:
By default, HTTP headers from incoming requests are **not** passed to your logic function for security reasons. To access specific headers, explicitly list them in the `forwardedRequestHeaders` array:
```typescript
export default defineLogicFunction({
export default defineFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'webhook-handler',
handler,
@@ -656,161 +567,21 @@ const handler = async (event: RoutePayload) => {
You can create new functions in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new logic function. This generates a starter file with a handler and config.
- **Manual**: Create a new `*.logic-function.ts` file and use `defineLogicFunction()`, following the same pattern.
- **Scaffolded**: Run `yarn entity:add` and choose the option to add a new function. This generates a starter file with a handler and config.
- **Manual**: Create a new `*.function.ts` file and use `defineFunction()`, following the same pattern.
### Marking a logic function as a tool
### Generated typed client
Logic functions can be exposed as **tools** for AI agents and workflows. When a function is marked as a tool, it becomes discoverable by Twenty's AI features and can be selected as a step in workflow automations.
To mark a logic function as a tool, set `isTool: true` and provide a `toolInputSchema` describing the expected input parameters using [JSON Schema](https://json-schema.org/):
Run yarn app:generate to create a local typed client in generated/ based on your workspace schema. Use it in your functions:
```typescript
// src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import Twenty from '~/generated';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
return { taskId: result.createTask.id };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
isTool: true,
toolInputSchema: {
type: 'object',
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
},
},
required: ['companyName'],
},
});
```
Key points:
- **`isTool`** (`boolean`, default: `false`): When set to `true`, the function is registered as a tool and becomes available to AI agents and workflow automations.
- **`toolInputSchema`** (`object`, optional): A JSON Schema object that describes the parameters your function accepts. AI agents use this schema to understand what inputs the tool expects and to validate calls. If omitted, the schema defaults to `{ type: 'object', properties: {} }` (no parameters).
- Functions with `isTool: false` (or unset) are **not** exposed as tools. They can still be executed directly or called by other functions, but will not appear in tool discovery.
- **Tool naming**: When exposed as a tool, the function name is automatically normalized to `logic_function_<name>` (lowercased, non-alphanumeric characters replaced with underscores). For example, `enrich-company` becomes `logic_function_enrich_company`.
- You can combine `isTool` with triggers — a function can be both a tool (callable by AI agents) and triggered by events (cron, database events, routes) at the same time.
<Note>
**Write a good `description`.** AI agents rely on the function's `description` field to decide when to use the tool. Be specific about what the tool does and when it should be called.
</Note>
### Front components
Front components let you build custom React components that render within Twenty's UI. Use `defineFrontComponent()` to define components with built-in validation:
```typescript
// src/front-components/my-widget.tsx
import { defineFrontComponent } from 'twenty-sdk';
const MyWidget = () => {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>My Custom Widget</h1>
<p>This is a custom front component for Twenty.</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'my-widget',
description: 'A custom widget component',
component: MyWidget,
});
```
Key points:
- Front components are React components that render in isolated contexts within Twenty.
- The `component` field references your React component.
- Components are built and synced automatically during `yarn twenty app:dev`.
You can create new front components in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new front component.
- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern.
### Skills
Skills define reusable instructions and capabilities that AI agents can use within your workspace. Use `defineSkill()` to define skills with built-in validation:
```typescript
// src/skills/example-skill.ts
import { defineSkill } from 'twenty-sdk';
export default defineSkill({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-outreach',
label: 'Sales Outreach',
description: 'Guides the AI agent through a structured sales outreach process',
icon: 'IconBrain',
content: `You are a sales outreach assistant. When reaching out to a prospect:
1. Research the company and recent news
2. Identify the prospect's role and likely pain points
3. Draft a personalized message referencing specific details
4. Keep the tone professional but conversational`,
});
```
Key points:
- `name` is a unique identifier string for the skill (kebab-case recommended).
- `label` is the human-readable display name shown in the UI.
- `content` contains the skill instructions — this is the text the AI agent uses.
- `icon` (optional) sets the icon displayed in the UI.
- `description` (optional) provides additional context about the skill's purpose.
You can create new skills in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
- **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
### Generated typed clients
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
```typescript
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
const client = new CoreApiClient();
const client = new Twenty();
const { me } = await client.query({ me: { id: true, displayName: true } });
const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
The client is re-generated by `yarn app:generate`. Re-run after changing your objects or when onboarding to a new workspace.
#### Runtime credentials in logic functions
@@ -821,84 +592,50 @@ When your function runs on Twenty, the platform injects credentials as environme
Notes:
- You do not need to pass URL or API key to the generated client. It reads `TWENTY_API_URL` and `TWENTY_API_KEY` from process.env at runtime.
- The API key's permissions are determined by the role referenced in your `application-config.ts` via `defaultRoleUniversalIdentifier`. This is the default role used by logic functions of your application.
- Applications can define roles to follow leastprivilege. Grant only the permissions your functions need, then point `defaultRoleUniversalIdentifier` to that role's universal identifier.
- The API key's permissions are determined by the role referenced in your `application.config.ts` via `roleUniversalIdentifier`. This is the default role used by logic functions of your application.
- Applications can define roles to follow leastprivilege. Grant only the permissions your functions need, then point `roleUniversalIdentifier` to that role's universal identifier.
#### Uploading files
The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
```typescript
import { MetadataApiClient } from 'twenty-sdk/generated';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type (defaults to 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
);
console.log(uploadedFile);
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
```
The method signature:
```typescript
uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string,
fieldMetadataUniversalIdentifier: string,
): Promise<{ id: string; path: string; size: number; createdAt: string; url: string }>
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `fileBuffer` | `Buffer` | The raw file contents |
| `filename` | `string` | The name of the file (used for storage and display) |
| `contentType` | `string` | MIME type of the file (defaults to `application/octet-stream` if omitted) |
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
Key points:
- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint.
- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else.
- The returned `url` is a signed URL you can use to access the uploaded file.
### Hello World example
Explore a minimal, end-to-end example that demonstrates objects, logic functions, front components, and multiple triggers [here](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/hello-world):
Explore a minimal, end-to-end example that demonstrates objects, functions, and multiple triggers [here](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/hello-world):
## Manual setup (without the scaffolder)
While we recommend using `create-twenty-app` for the best getting-started experience, you can also set up a project manually. Do not install the CLI globally. Instead, add `twenty-sdk` as a local dependency and wire a single script in your package.json:
While we recommend using `create-twenty-app` for the best getting-started experience, you can also set up a project manually. Do not install the CLI globally. Instead, add `twenty-sdk` as a local dependency and wire scripts in your package.json:
```bash filename="Terminal"
yarn add -D twenty-sdk
```
Then add a `twenty` script:
Then add scripts like these:
```json filename="package.json"
{
"scripts": {
"twenty": "twenty"
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"app:generate": "twenty app:generate",
"app:uninstall": "twenty app:uninstall",
"entity:add": "twenty entity:add",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"help": "twenty help"
}
}
```
Now you can run all commands via `yarn twenty <command>`, e.g. `yarn twenty app:dev`, `yarn twenty help`, etc.
Now you can run the same commands via Yarn, e.g. `yarn app:dev`, `yarn app:generate`, etc.
## Troubleshooting
- Authentication errors: run `yarn twenty auth:login` and ensure your API key has the required permissions.
- Authentication errors: run `yarn auth:login` and ensure your API key has the required permissions.
- Cannot connect to server: verify the API URL and that the Twenty server is reachable.
- Types or client missing/outdated: restart `yarn twenty app:dev` — it auto-generates the typed client.
- Dev mode not syncing: ensure `yarn twenty app:dev` is running and that changes are not ignored by your environment.
- Types or client missing/outdated: run `yarn app:generate`.
- Dev mode not syncing: ensure `yarn app:dev` is running and that changes are not ignored by your environment.
Discord Help Channel: https://discord.com/channels/1130383047699738754/1130386664812982322
@@ -11,7 +11,7 @@ title: كائنات مخصصة
## مخطط على مستوى عالي
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="مخطط على مستوى عالي" />
<img src="/images/docs/server/custom-object-schema.png" alt="مخطط على مستوى عالي" />
</div>
<br />
@@ -27,7 +27,7 @@ title: كائنات مخصصة
لإضافة كائن مخصص، سيقوم عضو مساحة العمل بالاستعلام عن واجهة برمجة التطبيقات /metadata. يقوم هذا بتحديث البيانات الوصفية وفقًا لذلك ويحسب مخطط GraphQL استنادًا إلى البيانات الوصفية، ويخزنها في ذاكرة GQL للاستخدام لاحقًا.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/add-custom-objects.jpeg" alt="استعلام واجهة برمجة التطبيقات /metadata لإضافة الكائنات المخصصة" />
<img src="/images/docs/server/add-custom-objects.jpeg" alt="استعلام واجهة برمجة التطبيقات /metadata لإضافة الكائنات المخصصة" />
</div>
<br />
@@ -35,5 +35,5 @@ title: كائنات مخصصة
لجلب البيانات، تتضمن العملية إجراء استعلامات من خلال نقطة النهاية /graphql وتمريرها من خلال محلل الاستعلام.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="استعلام نقطة النهاية /graphql لجلب البيانات" />
<img src="/images/docs/server/custom-object-schema.png" alt="استعلام نقطة النهاية /graphql لجلب البيانات" />
</div>
@@ -60,11 +60,9 @@ npx nx run twenty-server:command workspace:sync-metadata -f
```
<Warning>
سيؤدي هذا إلى إسقاط قاعدة البيانات وإعادة تشغيل الهجرات والبذور.
سيؤدي هذا إلى إسقاط قاعدة البيانات وإعادة تشغيل الهجرات والبذور.
تأكد من عمل نسخة احتياطية لأي بيانات تريد الاحتفاظ بها قبل تشغيل هذا الأمر.
تأكد من عمل نسخة احتياطية لأي بيانات تريد الاحتفاظ بها قبل تشغيل هذا الأمر.
</Warning>
## "التقنية المستخدمة"
@@ -43,9 +43,7 @@ cp .env.example .env
## التطوير
<Warning>
تأكد من تشغيل `yarn build` قبل أي أمر `zapier`.
تأكد من تشغيل `yarn build` قبل أي أمر `zapier`.
</Warning>
### تجربة
@@ -6,29 +6,24 @@ title: أفضل الممارسات
## إدارة الحالة
تقوم React و Jotai بإدارة الحالة في قاعدة الشيفرة.
تقوم React و Recoil بإدارة الحالة في قاعدة الشيفرة.
### استخدم ذرات Jotai لتخزين الحالة
### استخدم `useRecoilState` لتخزين الحالة
من الجيد إنشاء أكبر عدد ممكن من الذرات لتخزين الحالة الخاصة بك.
<Warning>
من الأفضل استخدام ذرات إضافية بدلاً من محاولة أن تكون مقتضبًا باستخدام تمرير الخصائص.
من الأفضل استخدام ذرات إضافية بدلاً من محاولة أن تكون مقتضبًا باستخدام تمرير الخصائص.
</Warning>
```tsx
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
export const myAtomState = createAtomState<string>({
export const myAtomState = atom({
key: 'myAtomState',
defaultValue: 'default value',
default: 'default value',
});
export const MyComponent = () => {
const [myAtom, setMyAtom] = useAtomState(myAtomState);
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
return (
<div>
@@ -45,7 +40,7 @@ export const MyComponent = () => {
تجنب استخدام `useRef` لتخزين الحالة.
إذا كنت ترغب في تخزين الحالة، يجب أن تستخدم `useState` أو ذرات Jotai مع `useAtomState`.
If you want to store state, you should use `useState` or `useRecoilState`.
انظر [كيفية إدارة إعادة العرض](#managing-re-renders) إذا شعرت أنك بحاجة إلى `useRef` لمنع بعض إعادة العرض من الحدوث.
@@ -82,11 +77,11 @@ If you feel like you need to add a `useEffect` in your root component, you shoul
يمكنك تطبيق نفس الشيء على منطق جلب البيانات، مع الخُطافات Apollo.
```tsx
// ❌ سيئ، سيتسبب في إعادة التصيير حتى إذا لم تتغير البيانات،
// ❌ سيّئ، سيتسبب في إعادة التصيير حتى إذا لم تتغير البيانات،
// لأن useEffect يحتاج إلى إعادة التقييم
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -98,7 +93,9 @@ export const PageComponent = () => {
};
export const App = () => (
<PageComponent />
<RecoilRoot>
<PageComponent />
</RecoilRoot>
);
```
@@ -106,14 +103,14 @@ export const App = () => (
// ✅ جيّد، لن يتسبب في إعادة التصيير إذا لم تتغير البيانات،
// لأن useEffect يُعاد تقييمه في مكوّن شقيق آخر
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [data, setData] = useRecoilState(dataState);
return <div>{data}</div>;
};
export const PageData = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -125,16 +122,16 @@ export const PageData = () => {
};
export const App = () => (
<>
<RecoilRoot>
<PageData />
<PageComponent />
</>
</RecoilRoot>
);
```
### استخدم حالات عائلة الذرات والمحددات
### استخدم حالات عائلة Recoil ومحددات عائلة Recoil
تُعد حالات عائلة الذرات والمحددات طريقة رائعة لتجنّب عمليات إعادة التصيير.
حالات عائلة Recoil والمحددات تعتبر طريقة رائعة لتجنب إعادة العرض.
إنها مفيدة عندما تحتاج إلى تخزين قائمة من العناصر.
@@ -82,9 +82,9 @@ module1
### الحالات
تشمل منطق إدارة الحالة. [Jotai](https://jotai.org) يتولّى ذلك.
تشمل منطق إدارة الحالة. [RecoilJS](https://recoiljs.org) يتولّى ذلك.
* المحددات: الذرات المشتقة (باستخدام `createAtomSelector`) تحسب قيماً من ذرات أخرى وتخزن نتائجها في الذاكرة مؤقتاً تلقائياً.
* المحددات: انظر [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) لمزيد من التفاصيل.
لا تزال إدارة الحالة المدمجة في React تتولّى الحالة داخل المكوّن.
@@ -49,7 +49,7 @@ title: أوامر الواجهة الأمامية
* "[React](https://react.dev/)"
* "[Apollo](https://www.apollographql.com/docs/)"
* "[GraphQL Codegen](https://the-guild.dev/graphql/codegen)"
* [Jotai](https://jotai.org/)
* "[Recoil](https://recoiljs.org/docs/introduction/core-concepts)"
* "[TypeScript](https://www.typescriptlang.org/)"
**الاختبار**
@@ -73,7 +73,7 @@ To avoid unnecessary [re-renders](/l/ar/developers/contribute/capabilities/front
### "إدارة الحالة"
[Jotai](https://jotai.org/) يتعامل مع إدارة الحالة.
"[Recoil](https://recoiljs.org/docs/introduction/core-concepts) يتعامل مع إدارة الحالة."
"راجع [أفضل الممارسات](/l/ar/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) لمزيد من المعلومات حول إدارة الحالة."
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
}
```
داخليًا، يتم تخزين النطاق المحدد حاليًا في ذرة Jotai مشتركة عبر التطبيق:
داخليًا، يتم تخزين النطاق المحدد حاليًا في حالة Recoil مشتركة عبر التطبيق:
```tsx
export const currentHotkeyScopeState = createState<HotkeyScope>({
@@ -169,10 +169,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
});
```
لكن لا يجب التعامل مع هذه الذرة يدويًا! سنرى كيف يمكن استخدامها في القسم التالي.
لكن لا يجب التعامل مع هذه الحالة Recoil يدويًا! سنرى كيف يمكن استخدامها في القسم التالي.
## كيف يعمل داخليًا؟
قمنا بإنشاء غلاف رقيق فوق [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) والذي يجعله أكثر كفاءة ويتجنب عمليات إعادة التقديم غير الضرورية.
ونقوم أيضًا بإنشاء ذرة Jotai للتعامل مع حالة نطاق مفاتيح الاختصار وجعلها متاحة في جميع أنحاء التطبيق.
ونقوم أيضًا بإنشاء حالة Recoil للتعامل مع حالة نطاق المفتاح وجعلها متاحة في جميع أنحاء التطبيق.

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