Compare commits

..
Author SHA1 Message Date
Thomas Trompette c5be4d2d60 [Bug] Fix broken variables for database event (#17526)
Fix https://github.com/twentyhq/twenty/issues/17524

Recent update meant to support variable with spaces and dots broken the
database event variables. Inserting a variable `My Name` will be
inserted as `{{trigger.[My Name]}}`

For example, for a database event, we send `trigger.properties.after.id`
instead of `id`.
With the new code, we will consider `properties.after.id` as a variable
to wrap in `[]`, giving `trigger.[properties.after.id]` which cannot be
resolve.

Let's only support variable with spaces. Removing the logic to wrap
variable with dots.
2026-01-28 18:41:14 +01:00
prastoin 70aae670f4 chore 2026-01-28 18:32:32 +01:00
prastoin 668cd7593b feat(server): refactor using validate build and run directly 2026-01-28 17:54:20 +01:00
prastoin 4180ba969b chore 2026-01-28 13:49:35 +01:00
prastoin 5065d75a6c logs 2026-01-28 11:35:01 +01:00
Etienne 715eed85be UpdateTaskOnDeleteActionCommand - Add logs (#17479) 2026-01-27 17:26:12 +01:00
Paul RastoinandEtienne 03651cab6c Invalidate and flush cache post 1.16 upgrade (#17465)
Breaking change requires cache flush
Centralized invalidation cache logic
```
[Nest] 42871  - 01/27/2026, 11:39:33 AM     LOG [FlushV2CacheAndIncrementMetadataVersionCommand] Flushing v2 cache and incrementing metadata version for workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db
[Runner] Cache invalidation flatFieldMetadataMaps,flatObjectMetadataMaps,flatViewMaps,flatViewFieldMaps,flatViewGroupMaps,flatRowLevelPermissionPredicateMaps,flatRowLevelPermissionPredicateGroupMaps,flatViewFilterGroupMaps,flatIndexMaps,flatServerlessFunctionMaps,flatCronTriggerMaps,flatDatabaseEventTriggerMaps,flatRouteTriggerMaps,flatViewFilterMaps,flatRoleMaps,flatRoleTargetMaps,flatAgentMaps,flatSkillMaps,flatPageLayoutMaps,flatPageLayoutWidgetMaps,flatPageLayoutTabMaps,flatCommandMenuItemMaps,flatNavigationMenuItemMaps,flatFrontComponentMaps: 81.41ms
```
2026-01-27 17:25:16 +01:00
Paul RastoinandEtienne 493e6a7f8f Invalidate flat cache command (#17442)
# Introduction
A command allowing to invalidate flat cache entries. Extends the active
or suspended workspace coverage.

## Args
### --metadataName
If provided will invalidate metadata and related metadata cache entry (
can be repeated see usage )

### --all-metadata
Will invalidate all metadata entries

## Usage example
```
npx nx command twenty-server cache:flat-cache-invalidate --metadataName viewFilter --metadataName objectMetadata
```

```
npx nx command twenty-server cache:flat-cache-invalidate --all-metadata -w 0000-0000-0000-0000
```
2026-01-27 17:24:51 +01:00
Paul Rastoinandprastoin c4775450f5 Backfill owner standard field check colliding joinColumnName (#17449)
# Introduction
Previously the command would have been `old` renaming only any `owner`
field on `opportunity` object
Now we also search for any colliding `joinColumnName` with `ownerId`
which is also introduced by the new standard owner field

In a nutshell: previously gracefully handling existing custom `owner`
field collision but not for RELATION types that has an additional
collision surface: `joinColumnName`

Related
https://github.com/twentyhq/twenty/issues/17413#issuecomment-3799872079
2026-01-26 16:50:19 +01:00
Paul Rastoinandprastoin cb6957fa51 Fix upgrade command order backfillStandardPageLayoutsCommand (#17430)
# Introduction
`backfillStandardPageLayoutsCommand` expects field and object metadata
to have been identified in prior to be run
https://github.com/twentyhq/twenty/issues/17413#issuecomment-3796933563
2026-01-25 21:53:29 +01:00
Paul Rastoinandprastoin 9633740132 [Debug log level] Print validation build result failure (#17423)
# Introduction
As per title, in order to ease debug
Motivation
https://github.com/twentyhq/twenty/issues/17413#issuecomment-3796168946
2026-01-25 12:38:16 +01:00
9580 changed files with 253586 additions and 508985 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
+1 -1
View File
@@ -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
+66 -76
View File
@@ -27,15 +27,11 @@ jobs:
packages/twenty-front/**
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:
@@ -89,7 +85,6 @@ jobs:
run: |
npx nx build twenty-shared
npx nx build twenty-ui
npx nx build twenty-sdk
- name: Install Playwright
run: |
cd packages/twenty-front
@@ -98,47 +93,47 @@ jobs:
run: npx nx reset:env twenty-front
- name: Run storybook tests
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
# - name: Rename coverage file
# run: |
# if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
# mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# else
# echo "Error: coverage-final.json not found"
# ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
# exit 1
# fi
# - name: Upload coverage artifact
# uses: actions/upload-artifact@v4
# with:
# retention-days: 1
# name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
# path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# merge-reports-and-check-coverage:
# timeout-minutes: 30
# runs-on: depot-ubuntu-24.04
# needs: front-sb-test
# env:
# PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
# strategy:
# matrix:
# storybook_scope: [modules, pages, performance]
# steps:
# - uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - uses: actions/download-artifact@v4
# with:
# pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
# merge-multiple: true
# path: coverage-artifacts
# - name: Merge coverage reports
# run: |
# mkdir -p ${{ env.PATH_TO_COVERAGE }}
# npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
# - name: Checking coverage
# run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
- name: Rename coverage file
run: |
if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
else
echo "Error: coverage-final.json not found"
ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
exit 1
fi
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
retention-days: 1
name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
merge-reports-and-check-coverage:
timeout-minutes: 30
runs-on: 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
@@ -169,9 +164,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 +192,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
@@ -229,14 +222,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 +289,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 +329,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 +356,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
+52 -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, test:integration]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -40,62 +39,64 @@ 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: Re-enable sdk-e2e-test once application sync is stable
# 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: 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 E2E Tests
# run: npx nx test:e2e twenty-sdk
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]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+1 -1
View File
@@ -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
-167
View File
@@ -1,167 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]
repository_dispatch:
types: [claude-core-team-issues]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.client_payload.issue_number }}
cancel-in-progress: false
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(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
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
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: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run Claude Code
id: claude-code
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: |
actions: read
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
settings: |
{
"env": {
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Post Create-PR link if Claude ran out of turns
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "" ]; then
exit 0
fi
AHEAD=$(git rev-list --count main.."$BRANCH" 2>/dev/null || echo "0")
if [ "$AHEAD" = "0" ]; then
exit 0
fi
EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
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")
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
gh issue comment "$ISSUE_NUMBER" --body "$(echo -e "$BODY")"
fi
claude-cross-repo:
if: github.event_name == 'repository_dispatch'
runs-on: depot-ubuntu-24.04
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build prompt from dispatch payload
id: prompt
uses: actions/github-script@v7
with:
script: |
const p = context.payload.client_payload;
let prompt;
if (p.comment_body) {
prompt = `You are responding to a comment on issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository.\n\nThe comment by @${p.sender} says:\n\n${p.comment_body}\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
} else {
prompt = `You are responding to issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository, opened by @${p.sender}.\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
}
core.setOutput('prompt', prompt);
core.setOutput('repo', p.repo_full_name);
core.setOutput('issue_number', p.issue_number);
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ steps.prompt.outputs.prompt }}
additional_permissions: |
actions: read
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
settings: |
{
"env": {
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Dispatch response to ci-privileged
if: always()
uses: peter-evans/repository-dispatch@v2
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)) }}}'
+2 -5
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
@@ -107,13 +107,10 @@ jobs:
- name: Regenerate docs.json
run: yarn docs:generate
- name: Regenerate documentation paths constants
run: yarn docs:generate-paths
- name: Commit artifacts to pull request branch
if: github.event_name == 'pull_request'
run: |
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json packages/twenty-shared/src/constants/DocumentationPaths.ts
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json
if git diff --staged --quiet --exit-code; then
echo "No navigation/doc changes to commit."
exit 0
+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');
-22
View File
@@ -22,28 +22,6 @@
"close": false
},
"problemMatcher": []
},
{
"label": "twenty-server - run unit test file",
"type": "shell",
"command": "npx nx run twenty-server:jest -- --config ./jest.config.mjs ${relativeFile} --silent=false ${input:watchMode} ${input:updateSnapshot}",
"options": {
"cwd": "${workspaceFolder}/packages/twenty-server",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
},
"shell": {
"executable": "/bin/zsh",
"args": ["-l", "-c"]
}
},
"presentation": {
"reveal": "always",
"panel": "new",
"close": false
},
"problemMatcher": []
}
],
"inputs": [
+31 -77
View File
@@ -21,33 +21,30 @@ npx nx run twenty-server:worker # Start background worker
### Testing
```bash
# Preferred: run a single test file (fast)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Run all tests for a package
# Run tests
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# To run an indivual test or a pattern of tests, use the following command:
cd packages/{workspace} && npx jest "pattern or filename"
# Storybook
npx nx storybook:build twenty-front
npx nx storybook:test twenty-front
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:test twenty-front # Run Storybook tests
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
```
### Code Quality
```bash
# Linting (diff with main - fastest, always prefer this)
npx nx lint:diff-with-main twenty-front
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
# Linting (diff with main - fastest)
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
npx nx lint:diff-with-main twenty-server # Lint only files changed vs main
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix files changed vs main
# Linting (full project - slower, use only when needed)
npx nx lint twenty-front
npx nx lint twenty-server
# Linting (full project)
npx nx lint twenty-front # Lint all files in frontend
npx nx lint twenty-server # Lint all files in backend
npx nx lint twenty-front --fix # Auto-fix all linting issues
# Type checking
npx nx typecheck twenty-front
@@ -60,8 +57,7 @@ npx nx fmt twenty-server
### Build
```bash
# Build packages (twenty-shared must be built first)
npx nx build twenty-shared
# Build packages
npx nx build twenty-front
npx nx build twenty-server
```
@@ -73,7 +69,7 @@ npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run migrations
# Generate migration (replace [name] with kebab-case descriptive name)
# Generate migration
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
# Sync metadata
@@ -82,15 +78,14 @@ npx nx run twenty-server:command workspace:sync-metadata
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
# Generate GraphQL types
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
```
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), 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
@@ -112,36 +107,13 @@ packages/
- **Named exports only** (no default exports)
- **Types over interfaces** (except when extending third-party interfaces)
- **String literals over enums** (except for GraphQL enums)
- **No 'any' type allowed** — strict TypeScript enforced
- **No 'any' type allowed**
- **Event handlers preferred over useEffect** for state updates
- **Props down, events up** — unidirectional data flow
- **Composition over inheritance**
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
### Naming Conventions
- **Variables/functions**: camelCase
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
- **TypeScript generics**: descriptive names (`TData` not `T`)
### File Structure
- Components under 300 lines, services under 500 lines
- Components in their own directories with tests and stories
- Use `index.ts` barrel exports for clean imports
- Import order: external libraries first, then internal (`@/`), then relative
### Comments
- Use short-form comments (`//`), not JSDoc blocks
- Explain WHY (business logic), not WHAT
- Do not comment obvious code
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- **Recoil** for global state management
- Component-specific state with React hooks
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
### Backend Architecture
- **NestJS modules** for feature organization
@@ -150,54 +122,36 @@ packages/
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database & Migrations
### Database
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **TypeORM migrations** for schema management
- **ClickHouse** for analytics (when enabled)
- Always generate migrations when changing entity files
- Migration names must be kebab-case (e.g. `add-agent-turn-evaluation`)
- Include both `up` and `down` logic in migrations
- Never delete or rewrite committed migrations
### Utility Helpers
Use existing helpers from `twenty-shared` instead of manual type guards:
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
## Development Workflow
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
### Before Making Changes
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
2. Test changes with relevant test suites (prefer single-file test runs)
3. Ensure database migrations are generated for entity changes
1. Always run linting and type checking after code changes
2. Test changes with relevant test suites
3. Ensure database migrations are properly structured
4. Check that GraphQL schema changes are backward compatible
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Emotion** for styling with styled-components pattern
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
- Components should be in their own directories with tests and stories
### Testing Strategy
- **Test behavior, not implementation** — focus on user perspective
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
- Query by user-visible elements (text, roles, labels) over test IDs
- Use `@testing-library/user-event` for realistic interactions
- Descriptive test names: "should [behavior] when [condition]"
- Clear mocks between tests with `jest.clearAllMocks()`
## CI Environment (GitHub Actions)
When running in CI, the dev environment is **not** pre-configured. Dependencies are installed but builds, env files, and databases are not set up.
- **Before running tests, builds, lint, type checks, or DB operations**, run: `bash packages/twenty-utils/setup-dev-env.sh`
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
- The script is idempotent and safe to run multiple times.
- **Unit tests** with Jest for both frontend and backend
- **Integration tests** for critical backend workflows
- **Storybook** for component development and testing
- **E2E tests** with Playwright for critical user flows
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
- `tsconfig.base.json` - Base TypeScript configuration
- `package.json` - Root package with workspace definitions
- `.cursor/rules/` - Detailed development guidelines and best practices
- `.cursor/rules/` - Development guidelines and best practices
+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 -19
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"
},
@@ -209,7 +210,6 @@
"scripts": {
"docs:generate": "tsx packages/twenty-docs/scripts/generate-docs-json.ts",
"docs:generate-navigation-template": "tsx packages/twenty-docs/scripts/generate-navigation-template.ts",
"docs:generate-paths": "tsx packages/twenty-docs/scripts/generate-documentation-paths.ts",
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
},
"workspaces": {
+27 -75
View File
@@ -15,12 +15,9 @@
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, generate, dev sync, oneoff sync, uninstall
- Strong TypeScript support and typed client generation
## Documentation
See Twenty application documentation https://docs.twenty.com/developers/extend/capabilities/apps
## Prerequisites
- Node.js 24+ (recommended) and Yarn 4
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
@@ -31,90 +28,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
# 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
# Generate a typed Twenty client and workspace entity types
yarn app:generate
# Watch your application's function logs
yarn twenty function:logs
# Start dev mode: automatically syncs local changes to your workspace
yarn app:dev
# Execute a function with a JSON payload
yarn twenty function:execute -n my-function -p '{"key": "value"}'
# Or run a onetime sync
yarn app:sync
# Execute the pre-install function
yarn twenty function:execute --preInstall
# Execute the post-install function
yarn twenty function:execute --postInstall
# Watch your application's functions logs
yarn function:logs
# 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`.
- Keep your types uptodate using `yarn app:generate`.
- Use `yarn app:dev` while you iterate to see changes instantly in your workspace.
## Publish your application
@@ -142,8 +94,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.3.1",
"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,41 @@ 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
```
Or run a one-time sync:
```bash
yarn app:sync
```
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
# 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 (sync + watch)
yarn app:sync # One-time sync
yarn entity:add # Add a new entity (function, 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,27 +26,30 @@ describe('copyBaseApplicationProject', () => {
});
afterEach(async () => {
// Clean up temp directory after each test
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
});
it('should create the correct folder structure with src/', async () => {
it('should create the correct folder structure with src/app/', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
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-function.role.ts exists in src/app/
const roleConfigPath = join(srcAppPath, 'default-function.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,9 @@ describe('copyBaseApplicationProject', () => {
const packageJson = await fs.readJson(packageJsonPath);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.version).toBe('0.1.0');
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.scripts['twenty']).toBe('twenty');
expect(packageJson.dependencies['twenty-sdk']).toBe('0.3.1');
expect(packageJson.scripts['app:sync']).toBe('twenty app:sync');
expect(packageJson.scripts['app:dev']).toBe('twenty app:dev');
});
it('should create .gitignore file', async () => {
@@ -101,7 +78,6 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const gitignorePath = join(testAppDirectory, '.gitignore');
@@ -109,7 +85,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 +94,6 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const yarnLockPath = join(testAppDirectory, 'yarn.lock');
@@ -128,76 +103,87 @@ 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 defineApp 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 defineApp
expect(appConfigContent).toContain(
"import { defineApplication } from 'twenty-sdk'",
"import { defineApp } from 'twenty-sdk'",
);
expect(appConfigContent).toContain('export default defineApplication({');
expect(appConfigContent).toContain('export default defineApp({');
// Verify it imports the role identifier
expect(appConfigContent).toContain(
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
"import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from 'src/default-function.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',
'functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER',
);
});
it('should create default-role.ts with defineRole and correct values', async () => {
it('should create default-function.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,
'default-function.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',
'export const DEFAULT_FUNCTION_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/,
/universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER/,
);
});
@@ -207,9 +193,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 +209,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 +230,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 +240,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 +264,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 +272,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,21 +282,21 @@ describe('copyBaseApplicationProject', () => {
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
exampleOptions: ALL_EXAMPLES,
});
// Read both role configs
const firstRoleConfig = await fs.readFile(
join(firstAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
join(firstAppDir, 'src', 'default-function.role.ts'),
'utf8',
);
const secondRoleConfig = await fs.readFile(
join(secondAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
join(secondAppDir, 'src', 'default-function.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})'/;
/DEFAULT_FUNCTION_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];
const secondUuid = secondRoleConfig.match(uuidRegex)?.[1];
@@ -312,489 +304,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);
});
});
});
@@ -1,25 +1,19 @@
import * as fs from 'fs-extra';
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';
const APP_FOLDER = 'src';
export const copyBaseApplicationProject = async ({
appName,
appDisplayName,
appDescription,
appDirectory,
exampleOptions,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
exampleOptions: ExampleOptions;
}) => {
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
@@ -27,101 +21,32 @@ export const copyBaseApplicationProject = async ({
await createGitignore(appDirectory);
await createPublicAssetDirectory(appDirectory);
await createYarnLock(appDirectory);
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
const appFolderPath = join(appDirectory, APP_FOLDER);
await fs.ensureDir(sourceFolderPath);
await fs.ensureDir(appFolderPath);
await createDefaultRoleConfig({
await createDefaultServerlessFunctionRoleConfig({
displayName: appDisplayName,
appDirectory: sourceFolderPath,
fileFolder: 'roles',
fileName: 'default-role.ts',
appDirectory: appFolderPath,
});
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({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'pre-install.ts',
await createDefaultFrontComponent({
appDirectory: appFolderPath,
});
await createDefaultPostInstallFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'post-install.ts',
await createDefaultFunction({
appDirectory: appFolderPath,
});
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
appDirectory: sourceFolderPath,
fileName: 'application-config.ts',
appDirectory: appFolderPath,
});
};
const createPublicAssetDirectory = async (appDirectory: string) => {
await fs.ensureDir(join(appDirectory, ASSETS_DIR));
};
const createYarnLock = async (appDirectory: string) => {
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
@@ -146,7 +71,6 @@ generated
# dev
/dist/
.twenty
# production
@@ -172,26 +96,22 @@ yarn-error.log*
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
};
const createDefaultRoleConfig = async ({
const createDefaultServerlessFunctionRoleConfig = async ({
displayName,
appDirectory,
fileFolder,
fileName,
}: {
displayName: string;
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
label: '${displayName} default function role',
description: '${displayName} default function role',
canReadAllObjectRecords: true,
@@ -201,18 +121,13 @@ export default defineRole({
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
await fs.writeFile(join(appDirectory, 'default-function.role.ts'), content);
};
const createDefaultFrontComponent = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
@@ -235,293 +150,68 @@ 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';
const content = `import { defineFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
return { message: 'Hello, World!' };
};
export default defineLogicFunction({
export default defineFunction({
universalIdentifier: '${universalIdentifier}',
name: 'hello-world-logic-function',
description: 'A simple logic function',
name: 'hello-world-function',
description: 'A sample serverless 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-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.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';
const content = `import { defineApp } from 'twenty-sdk';
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from 'src/default-function.role';
export default defineApplication({
export default defineApp({
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
functionRoleUniversalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
});
`;
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 +232,31 @@ 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',
'app:build': 'twenty app:build',
'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: {},
dependencies: {
'twenty-sdk': '0.3.1',
},
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;
@@ -95,7 +95,7 @@ export class PostCard {
label: 'Notes',
icon: 'IconComment',
inverseSideTargetUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.note.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.note,
onDelete: OnDeleteAction.CASCADE,
})
notes: Note[];
@@ -14,7 +14,7 @@ export const functionRole: RoleConfig = {
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
objectNameSingular: 'postCard',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
@@ -23,8 +23,8 @@ export const functionRole: RoleConfig = {
],
fieldPermissions: [
{
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff',
objectNameSingular: 'postCard',
fieldName: 'content',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -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
@@ -0,0 +1,21 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
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,
},
},
};
export default config;
@@ -8,14 +8,11 @@
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "eslint",
"lint:fix": "eslint --fix"
"dependencies": {
"twenty-sdk": "0.0.4"
},
"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"
@@ -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,
});
@@ -0,0 +1,18 @@
import { FieldMetadata, FieldMetadataType, ObjectMetadata } from 'twenty-sdk/application';
@ObjectMetadata({
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
nameSingular: 'selfHostingUser',
namePlural: 'selfHostingUsers',
labelSingular: 'Self Hosting User',
labelPlural: 'Self Hosting Users',
})
export class SelfHostingUser {
@FieldMetadata({
type: FieldMetadataType.EMAILS,
label: 'Email',
description: 'The email of the self hosting user',
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
})
email: object;
}
@@ -0,0 +1,114 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
import { createClient } from '../generated';
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: TelemetryEventPayload,
): Promise<{ success: boolean; message: string; error?: string }> => {
try {
const { action, payload } = params;
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 const config: ServerlessFunctionConfig = {
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
name: 'telemetry-webhook',
timeoutSeconds: 5,
triggers: [
{
universalIdentifier: '7c8e3f5a-9b4c-4d1e-8f2a-1b3c4d5e6f7a',
type: 'route',
path: '/webhook/telemetry',
httpMethod: 'POST',
isAuthRequired: 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,
@@ -21,11 +20,12 @@
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
"resolveJsonModule": true
},
"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
@@ -42,50 +42,25 @@
{{- regexFind "\\|(.+)$" . | trimPrefix "|" -}}
{{- end -}}
{{/* Check if using external secret for database password */}}
{{- define "twenty.db.useExternalSecret" -}}
{{- if and (not .Values.db.enabled) .Values.db.external.secretName .Values.db.external.passwordKey -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}
{{/* Database URL secret name */}}
{{- define "twenty.dbUrl.secretName" -}}
{{- printf "%s-db-url" (include "twenty.fullname" .) -}}
{{- end -}}
{{/* Database password secret name */}}
{{- define "twenty.dbPassword.secretName" -}}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" -}}
{{- .Values.db.external.secretName -}}
{{- else -}}
{{- include "twenty.dbUrl.secretName" . -}}
{{- end -}}
{{- end -}}
{{/* Database password secret key */}}
{{- define "twenty.dbPassword.secretKey" -}}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" -}}
{{- .Values.db.external.passwordKey -}}
{{/* Compose DB connection URL */}}
{{- define "twenty.dbUrl" -}}
{{- if .Values.server.env.PG_DATABASE_URL -}}
{{- .Values.server.env.PG_DATABASE_URL -}}
{{- else if .Values.db.enabled -}}
appPassword
{{- $host := printf "%s-db" (include "twenty.fullname" .) -}}
{{- $user := .Values.db.internal.appUser | default "twenty_app_user" -}}
{{- $pass := .Values.db.internal.appPassword | default (randAlphaNum 32) -}}
{{- $db := .Values.db.internal.database | default "twenty" -}}
{{- printf "postgres://%s:%s@%s.%s.svc.cluster.local/%s" $user $pass $host (include "twenty.namespace" .) $db -}}
{{- else -}}
password
{{- end -}}
{{- end -}}
{{/* Database URL template for external secret (will be evaluated at runtime) */}}
{{- define "twenty.dbUrl.template" -}}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" -}}
{{- $scheme := "postgres" -}}
{{- $host := .Values.db.external.host -}}
{{- $port := .Values.db.external.port | default 5432 -}}
{{- $user := .Values.db.external.user | default "postgres" -}}
{{- $pass := .Values.db.external.password | default "postgres" -}}
{{- $db := .Values.db.external.database | default "twenty" -}}
{{- $qs := ternary "?sslmode=require" "" (eq .Values.db.external.ssl true) -}}
{{- printf "%s://%s:$(DB_PASSWORD)@%s:%v/%s%s" $scheme $user $host $port $db $qs -}}
{{- printf "%s://%s:%s@%s:%v/%s%s" $scheme $user $pass $host $port $db $qs -}}
{{- end -}}
{{- end -}}
@@ -103,11 +78,9 @@ password
{{- end -}}
{{- end -}}
{{/* Compose Server URL from override, ingress, or service */}}
{{/* Compose Server URL from ingress, else service */}}
{{- define "twenty.serverUrl" -}}
{{- if .Values.server.env.SERVER_URL -}}
{{- .Values.server.env.SERVER_URL -}}
{{- else if and .Values.server.ingress.enabled (gt (len .Values.server.ingress.hosts) 0) -}}
{{- if and .Values.server.ingress.enabled (gt (len .Values.server.ingress.hosts) 0) -}}
{{- $host := (index .Values.server.ingress.hosts 0).host -}}
{{- $tls := gt (len .Values.server.ingress.tls) 0 -}}
{{- $scheme := ternary "https" "http" $tls -}}
@@ -116,21 +116,11 @@ spec:
- >-
npx -y typeorm migration:run -d dist/database/typeorm/core/core.datasource
env:
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
name: {{ include "twenty.fullname" . }}-db-url
key: url
{{- end }}
containers:
- name: server
{{- $img := include "twenty.server.image" . }}
@@ -139,21 +129,11 @@ spec:
env:
- name: SERVER_URL
value: {{ include "twenty.serverUrl" . | quote }}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
name: {{ include "twenty.fullname" . }}-db-url
key: url
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: SIGN_IN_PREFILLED
@@ -52,21 +52,11 @@ spec:
env:
- name: SERVER_URL
value: {{ include "twenty.serverUrl" . | quote }}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
name: {{ include "twenty.fullname" . }}-db-url
key: url
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: STORAGE_TYPE
@@ -1,5 +1,4 @@
{{- $secretName := printf "%s-db-url" (include "twenty.fullname" .) -}}
{{- if .Values.db.enabled -}}
{{- $existingSecret := lookup "v1" "Secret" (include "twenty.namespace" .) $secretName -}}
{{- $appPassword := "" -}}
{{- if $existingSecret -}}
@@ -21,25 +20,3 @@ type: Opaque
stringData:
url: {{ printf "postgres://%s:%s@%s-db.%s.svc.cluster.local/%s" (urlquery $appUser) (urlquery $appPassword) (include "twenty.fullname" .) (include "twenty.namespace" .) (.Values.db.internal.database | default "twenty") | quote }}
appPassword: {{ $appPassword | quote }}
{{- else if not .Values.db.external.secretName -}}
{{- $scheme := "postgres" -}}
{{- $host := .Values.db.external.host -}}
{{- $port := .Values.db.external.port | default 5432 -}}
{{- $user := .Values.db.external.user | default "postgres" -}}
{{- $pass := .Values.db.external.password | default "" -}}
{{- $db := .Values.db.external.database | default "twenty" -}}
{{- $qs := ternary "?sslmode=require" "" (eq .Values.db.external.ssl true) -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
type: Opaque
stringData:
url: {{ printf "%s://%s:%s@%s:%v/%s%s" $scheme (urlquery $user) (urlquery $pass) $host $port $db $qs | quote }}
password: {{ $pass | quote }}
{{- end -}}
@@ -45,7 +45,6 @@
"env": {
"type": "object",
"properties": {
"SERVER_URL": { "type": "string", "description": "Override for the server URL (e.g., https://crm.example.com). If not set, derived from ingress or service." },
"NODE_PORT": { "type": "integer", "minimum": 1 },
"PG_DATABASE_URL": { "type": "string" },
"REDIS_URL": { "type": "string" },
@@ -19,13 +19,8 @@ storage:
bucket: ""
region: ""
endpoint: ""
# Option A: direct values
accessKeyId: ""
secretAccessKey: ""
# Option B: reference a Secret
# secretName: my-s3-creds
# accessKeyIdKey: accessKeyId
# secretAccessKeyKey: secretAccessKey
# Auth tokens (random if not provided)
secrets:
@@ -142,8 +137,6 @@ db:
password: ""
database: twenty
ssl: false
secretName: ""
passwordKey: ""
# Redis
redisInternal:
-2
View File
@@ -14,7 +14,6 @@ COPY ./packages/twenty-server/patches /app/packages/twenty-server/patches
COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
COPY ./packages/twenty-front/package.json /app/packages/twenty-front/
COPY ./packages/twenty-sdk/package.json /app/packages/twenty-sdk/
# Install all dependencies
RUN yarn && yarn cache clean && npx nx reset
@@ -40,7 +39,6 @@ ARG REACT_APP_SERVER_BASE_URL
COPY ./packages/twenty-front /app/packages/twenty-front
COPY ./packages/twenty-ui /app/packages/twenty-ui
COPY ./packages/twenty-shared /app/packages/twenty-shared
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
RUN npx nx build twenty-front
@@ -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.

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