Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4775450f5 | ||
|
|
cb6957fa51 | ||
|
|
9633740132 |
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -34,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/[email protected]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -115,7 +110,7 @@ jobs:
|
||||
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
|
||||
runs-on: ubuntu-latest
|
||||
needs: front-sb-test
|
||||
env:
|
||||
PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
|
||||
@@ -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
|
||||
@@ -236,7 +229,7 @@ jobs:
|
||||
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() &&
|
||||
@@ -346,7 +339,7 @@ jobs:
|
||||
ci-front-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
[
|
||||
changed-files-check,
|
||||
@@ -363,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/[email protected]
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,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
|
||||
@@ -38,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,174 +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; print(urllib.parse.quote('$BRANCH', safe=''))")
|
||||
PR_URL="https://github.com/${{ github.repository }}/compare/main...${ENCODED_BRANCH}?quick_pull=1"
|
||||
BODY="⚠️ Claude ran out of turns before creating a PR. Work has been pushed to [\`$BRANCH\`](https://github.com/${{ github.repository }}/tree/$ENCODED_BRANCH).\n\n[**Create PR →**]($PR_URL)"
|
||||
if [ -n "$ISSUE_NUMBER" ]; then
|
||||
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: Post response to source issue
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.TWENTY_DISPATCH_TOKEN }}
|
||||
script: |
|
||||
const [owner, repo] = '${{ steps.prompt.outputs.repo }}'.split('/');
|
||||
const issueNumber = parseInt('${{ steps.prompt.outputs.issue_number }}', 10);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `Claude finished processing this request. [See workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
jobs:
|
||||
qa_report:
|
||||
name: Generate QA Report
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
trigger-preview:
|
||||
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || (github.event.action == 'labeled' && github.event.label.name == 'preview-app')
|
||||
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
|
||||
|
||||
@@ -17,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..."
|
||||
@@ -25,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
|
||||
@@ -46,24 +46,24 @@ jobs:
|
||||
cd packages/twenty-docker/
|
||||
docker compose build
|
||||
working-directory: ./
|
||||
|
||||
|
||||
- name: Create Tunnel
|
||||
id: expose-tunnel
|
||||
uses: codetalkio/[email protected]
|
||||
with:
|
||||
service: bore.pub
|
||||
port: 3000
|
||||
|
||||
|
||||
- name: Start services with correct SERVER_URL
|
||||
run: |
|
||||
cd packages/twenty-docker/
|
||||
|
||||
|
||||
# Update the SERVER_URL with the tunnel URL
|
||||
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
|
||||
sed -i '/SERVER_URL=/d' .env
|
||||
echo "" >> .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: ./
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
echo "✅ Preview Environment Ready!"
|
||||
echo "🔗 Preview URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}"
|
||||
echo "⏱️ This environment will be available for 5 hours"
|
||||
|
||||
|
||||
- name: Post comment on PR
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
@@ -113,21 +113,21 @@ jobs:
|
||||
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({
|
||||
@@ -147,13 +147,13 @@ jobs:
|
||||
});
|
||||
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: ./
|
||||
|
||||
@@ -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
@@ -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');
|
||||
Vendored
-22
@@ -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": [
|
||||
|
||||
@@ -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,9 +78,8 @@ 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
|
||||
@@ -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
|
||||
- **Recoil** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
|
||||
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
|
||||
- **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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
+2
-8
@@ -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'],
|
||||
@@ -203,7 +197,7 @@ export default [
|
||||
plugins: {
|
||||
...mdxPlugin.flat.plugins,
|
||||
'@nx': nxPlugin,
|
||||
twenty: { rules: twentyRules },
|
||||
'twenty': { rules: twentyRules },
|
||||
},
|
||||
},
|
||||
mdxPlugin.flatCodeBlocks,
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
"outputs": ["{projectRoot}/coverage"],
|
||||
"options": {
|
||||
"jestConfig": "{projectRoot}/jest.config.mjs",
|
||||
"silent": true,
|
||||
"coverage": true,
|
||||
"coverageReporters": ["text-summary"],
|
||||
"cacheDirectory": "../../.cache/jest/{projectRoot}"
|
||||
@@ -273,6 +272,9 @@
|
||||
"inputs": ["default", "^default"]
|
||||
}
|
||||
},
|
||||
"installation": {
|
||||
"version": "22.3.3"
|
||||
},
|
||||
"generators": {
|
||||
"@nx/react": {
|
||||
"application": {
|
||||
|
||||
+8
-11
@@ -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",
|
||||
@@ -91,10 +90,10 @@
|
||||
"@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",
|
||||
@@ -137,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",
|
||||
@@ -183,11 +182,10 @@
|
||||
"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",
|
||||
@@ -212,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": {
|
||||
|
||||
@@ -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 ready‑to‑run project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
|
||||
|
||||
- Zero‑config project bootstrap
|
||||
- Preconfigured scripts for auth, dev mode (watch & sync), uninstall, and function management
|
||||
- Preconfigured scripts for auth, generate, dev sync, one‑off 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)
|
||||
@@ -35,86 +32,41 @@ cd my-twenty-app
|
||||
corepack enable
|
||||
yarn install
|
||||
|
||||
# Get help and list all available commands
|
||||
yarn twenty help
|
||||
# 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 a typed API client 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 one‑time sync
|
||||
yarn app:sync
|
||||
|
||||
# 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/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.
|
||||
- Types are auto‑generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`.
|
||||
- Explore the generated project and add your first entity with `yarn entity:add`.
|
||||
- Keep your types up‑to‑date 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 auto‑generates 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 re‑start `yarn app:dev`.
|
||||
|
||||
## Contributing
|
||||
- See our [GitHub](https://github.com/twentyhq/twenty)
|
||||
|
||||
@@ -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,13 +1,11 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.6.0",
|
||||
"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"
|
||||
|
||||
@@ -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,9 +0,0 @@
|
||||
## Base documentation
|
||||
|
||||
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
|
||||
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
|
||||
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
|
||||
@@ -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;
|
||||
|
||||
@@ -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,9 +1,7 @@
|
||||
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 { 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', () => {
|
||||
@@ -14,29 +12,6 @@ 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;
|
||||
|
||||
@@ -57,25 +32,24 @@ describe('copyBaseApplicationProject', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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/ folder exists
|
||||
// Verify src/app/ folder exists
|
||||
const srcAppPath = join(testAppDirectory, 'src');
|
||||
expect(await fs.pathExists(srcAppPath)).toBe(true);
|
||||
|
||||
// Verify application-config.ts exists in src/
|
||||
const appConfigPath = join(srcAppPath, APPLICATION_FILE_NAME);
|
||||
// Verify application.config.ts exists in src/app/
|
||||
const appConfigPath = join(srcAppPath, 'application.config.ts');
|
||||
expect(await fs.pathExists(appConfigPath)).toBe(true);
|
||||
|
||||
// Verify default-role.ts exists in src/
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -85,7 +59,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const packageJsonPath = join(testAppDirectory, 'package.json');
|
||||
@@ -94,8 +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.dependencies['twenty-sdk']).toBe('latest');
|
||||
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 () => {
|
||||
@@ -104,7 +78,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const gitignorePath = join(testAppDirectory, '.gitignore');
|
||||
@@ -112,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 () => {
|
||||
@@ -121,7 +94,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const yarnLockPath = join(testAppDirectory, 'yarn.lock');
|
||||
@@ -131,27 +103,30 @@ 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 defineApplication
|
||||
// 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
|
||||
@@ -165,24 +140,22 @@ describe('copyBaseApplicationProject', () => {
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -194,7 +167,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
|
||||
// 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
|
||||
@@ -210,7 +183,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
|
||||
// Verify it has a universalIdentifier (UUID format)
|
||||
expect(roleConfigContent).toMatch(
|
||||
/universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER/,
|
||||
/universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -220,7 +193,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Verify fs.copy was called with correct destination
|
||||
@@ -237,10 +209,13 @@ 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: ''");
|
||||
@@ -255,7 +230,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
appDisplayName: 'App One',
|
||||
appDescription: 'First app',
|
||||
appDirectory: firstAppDir,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
@@ -266,16 +240,15 @@ 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',
|
||||
);
|
||||
|
||||
@@ -299,7 +272,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
appDisplayName: 'App One',
|
||||
appDescription: 'First app',
|
||||
appDirectory: firstAppDir,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
@@ -310,22 +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];
|
||||
|
||||
@@ -333,347 +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);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// Core files should exist
|
||||
expect(await fs.pathExists(join(srcPath, APPLICATION_FILE_NAME))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
await fs.pathExists(join(srcPath, 'roles', DEFAULT_ROLE_FILE_NAME)),
|
||||
).toBe(true);
|
||||
|
||||
// Example files should not exist
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +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';
|
||||
|
||||
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);
|
||||
|
||||
@@ -26,95 +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',
|
||||
});
|
||||
}
|
||||
await createDefaultFrontComponent({
|
||||
appDirectory: appFolderPath,
|
||||
});
|
||||
|
||||
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 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
|
||||
@@ -139,9 +71,7 @@ generated
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
.twenty/*
|
||||
!.twenty/output/
|
||||
.twenty
|
||||
|
||||
# production
|
||||
/build
|
||||
@@ -166,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,
|
||||
@@ -195,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();
|
||||
|
||||
@@ -229,267 +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 createDefaultPostInstallFunction = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
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';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
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,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_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 ({
|
||||
@@ -510,18 +232,31 @@ const createPackageJson = async ({
|
||||
},
|
||||
packageManager: '[email protected]',
|
||||
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: {
|
||||
'twenty-sdk': 'latest',
|
||||
'twenty-sdk': '0.3.1',
|
||||
},
|
||||
devDependencies: {
|
||||
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',
|
||||
},
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"types": ["jest", "node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react"
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
import { defineApp } from 'twenty-sdk';
|
||||
import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
|
||||
export default defineApp({
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
|
||||
displayName: 'Self Hosting',
|
||||
description: 'Used to manage billing and telemetry of self-hosted instances',
|
||||
@@ -16,4 +16,6 @@ export default defineApp({
|
||||
isSecret: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -8,24 +8,8 @@
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"auth:login": "twenty auth login",
|
||||
"auth:logout": "twenty auth logout",
|
||||
"auth:status": "twenty auth status",
|
||||
"auth:switch": "twenty auth switch",
|
||||
"auth:list": "twenty auth list",
|
||||
"app:dev": "twenty app dev",
|
||||
"app:sync": "twenty app sync",
|
||||
"entity:add": "twenty entity add",
|
||||
"function:logs": "twenty function logs",
|
||||
"function:execute": "twenty function execute",
|
||||
"app:uninstall": "twenty app uninstall",
|
||||
"help": "twenty help",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "0.3.1"
|
||||
"twenty-sdk": "0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { FieldType, defineObject } from 'twenty-sdk';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
|
||||
nameSingular: 'selfHostingUser',
|
||||
namePlural: 'selfHostingUsers',
|
||||
labelSingular: 'Self Hosting User',
|
||||
labelPlural: 'Self Hosting Users',
|
||||
fields: [
|
||||
{
|
||||
type: FieldType.EMAILS,
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
description: 'The email of the self hosting user',
|
||||
universalIdentifier: 'a4b7892c-431a-4d44-973e-a5481652704f',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { defineFunction } from 'twenty-sdk';
|
||||
import { createClient } from '../../generated';
|
||||
|
||||
// TODO: import from twenty-sdk when 0.4.0 is deployed
|
||||
type ServerlessFunctionEvent<TBody = object> = {
|
||||
headers: Record<string, string | undefined>;
|
||||
queryStringParameters: Record<string, string | undefined>;
|
||||
pathParameters: Record<string, string | undefined>;
|
||||
body: TBody | null;
|
||||
isBase64Encoded: boolean;
|
||||
requestContext: {
|
||||
http: {
|
||||
method: string;
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type TelemetryEventPayload = {
|
||||
action: string;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
payload: {
|
||||
userId: string | null;
|
||||
workspaceId: string | null;
|
||||
payload?: {
|
||||
events?: Array<{
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
userFirstName?: string;
|
||||
userLastName?: string;
|
||||
locale?: string;
|
||||
serverUrl?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const main = async (
|
||||
params: ServerlessFunctionEvent<TelemetryEventPayload>,
|
||||
): Promise<{ success: boolean; message: string; error?: string }> => {
|
||||
try {
|
||||
const { action, payload } = params.body || {};
|
||||
|
||||
if (action !== 'user_signup') {
|
||||
return {
|
||||
success: true,
|
||||
message: `Event type '${action}' ignored`,
|
||||
};
|
||||
}
|
||||
|
||||
const userEmail =
|
||||
payload?.payload?.events?.[0]?.userEmail ||
|
||||
payload?.payload?.events?.[0]?.userId;
|
||||
|
||||
if (!userEmail) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No email found in telemetry event',
|
||||
error: 'Missing userEmail in payload',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
userEmail.toLowerCase().includes('example') ||
|
||||
userEmail.toLowerCase().includes('test')
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
message: `Email '${userEmail}' ignored (contains test/example data)`,
|
||||
};
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
url: `${process.env.TWENTY_API_URL}/graphql`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Create or update selfHostingUser record
|
||||
const result = await client.mutation({
|
||||
createSelfHostingUser: {
|
||||
__args: {
|
||||
data: {
|
||||
name:
|
||||
payload?.payload?.events?.[0]?.userFirstName +
|
||||
' ' +
|
||||
payload?.payload?.events?.[0]?.userLastName,
|
||||
email: {
|
||||
primaryEmail: userEmail,
|
||||
additionalEmails: null,
|
||||
},
|
||||
},
|
||||
upsert: true,
|
||||
},
|
||||
id: true,
|
||||
email: {
|
||||
primaryEmail: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Self hosting user created/updated: ${result.createSelfHostingUser?.id}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to process telemetry event',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default defineFunction({
|
||||
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
|
||||
name: 'telemetry-webhook',
|
||||
timeoutSeconds: 5,
|
||||
handler: main,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: '7c8e3f5a-9b4c-4d1e-8f2a-1b3c4d5e6f7a',
|
||||
type: 'route',
|
||||
path: '/webhook/telemetry',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -20,11 +20,7 @@
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
.twenty/*
|
||||
!.twenty/output/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
@@ -1 +0,0 @@
|
||||
24.5.0
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "twentyfortwenty",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "latest",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"eslint": "^9.32.0",
|
||||
"react": "^18.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.0"
|
||||
}
|
||||
}
|
||||
-68802
File diff suppressed because it is too large
Load Diff
-7
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -1,9 +0,0 @@
|
||||
## Base documentation
|
||||
|
||||
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
|
||||
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
|
||||
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
|
||||
@@ -1,51 +0,0 @@
|
||||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty auth:login
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
|
||||
```bash
|
||||
yarn twenty app:dev
|
||||
```
|
||||
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Available Commands
|
||||
|
||||
Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Authentication
|
||||
yarn twenty auth:login # Authenticate with Twenty
|
||||
yarn twenty auth:logout # Remove credentials
|
||||
yarn twenty auth:status # Check auth status
|
||||
yarn twenty auth:switch # Switch default workspace
|
||||
yarn twenty auth:list # List all configured workspaces
|
||||
|
||||
# Application
|
||||
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty function:logs # Stream function logs
|
||||
yarn twenty function:execute # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
Main docs and pitfalls are available in LLMS.md file.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Twenty applications, take a look at the following resources:
|
||||
|
||||
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
|
||||
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
|
||||
@@ -1,29 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
// Base JS recommended rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript recommended rules
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Common TypeScript-friendly tweaks
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-unused-vars': 'off', // handled by TS rule
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "twentyfortwenty",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "latest",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"eslint": "^9.32.0",
|
||||
"react": "^18.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.0"
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '0fac8de4-9d11-492b-9e6a-577e11e1c442',
|
||||
displayName: 'Twenty for Twenty',
|
||||
description: '',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
applicationVariables: {
|
||||
CLICKHOUSE_URL: {
|
||||
universalIdentifier: 'b204304f-d3d3-4ae2-aafa-24b21c159181',
|
||||
description: 'The URL of the ClickHouse server, including the protocol and port (e.g., http://localhost:8123)',
|
||||
isSecret: true,
|
||||
},
|
||||
CLICKHOUSE_USERNAME: {
|
||||
universalIdentifier: '711e8ea8-8b19-4cf0-82ab-ab44417312bd',
|
||||
description: 'The username for authenticating with the ClickHouse server',
|
||||
isSecret: true,
|
||||
},
|
||||
CLICKHOUSE_PASSWORD: {
|
||||
universalIdentifier: 'faba7ed7-9f94-4202-944b-c25c683e9504',
|
||||
description: 'The password for authenticating with the ClickHouse server',
|
||||
isSecret: true,
|
||||
},
|
||||
CLICKHOUSE_DATABASE: {
|
||||
universalIdentifier: '3e6698fa-0c00-49e5-9c4d-34d5b177bff3',
|
||||
description: 'The name of the ClickHouse database to connect to',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
// Field metadata identifiers for cloudUser2 object
|
||||
// These constants are used by both the object definition and views
|
||||
|
||||
export const CLOUD_USER_2_ACTIVITY_STATUS_FIELD_ID =
|
||||
'56e3d6ce-d57e-4ded-830f-958eb18d4d36';
|
||||
export const CLOUD_USER_2_AVG_DAILY_PAGEVIEWS_LAST_30D_FIELD_ID =
|
||||
'1f7062c1-0e96-4599-a59c-6d7427794bb1';
|
||||
export const CLOUD_USER_2_DATA_LAST_UPDATED_AT_FIELD_ID =
|
||||
'e134ce6b-5ab0-4e9c-ba32-36d0851aca21';
|
||||
export const CLOUD_USER_2_DAYS_SINCE_LAST_ACTIVITY_FIELD_ID =
|
||||
'f305d341-7b12-4939-9299-e5e1b6d1591e';
|
||||
export const CLOUD_USER_2_EMAIL_FIELD_ID =
|
||||
'ee110a77-34d7-4c8b-bc10-8560b9e2333a';
|
||||
export const CLOUD_USER_2_FULL_NAME_FIELD_ID =
|
||||
'3c9540be-6e42-40f2-8598-973117bbe105';
|
||||
export const CLOUD_USER_2_IS_ACTIVE_L24H_FIELD_ID =
|
||||
'efaa6e54-8019-454c-a556-2e340f0b156d';
|
||||
export const CLOUD_USER_2_IS_ACTIVE_L30D_FIELD_ID =
|
||||
'86a07ca4-420e-4c82-82f8-8f8dfbfc2dc4';
|
||||
export const CLOUD_USER_2_IS_ACTIVE_L7D_FIELD_ID =
|
||||
'87bf8c09-1863-4c65-811f-6fb54a87238e';
|
||||
export const CLOUD_USER_2_IS_TWENTY_FIELD_ID =
|
||||
'f6dcd9f3-3f7e-4aea-84a5-41282a68910d';
|
||||
export const CLOUD_USER_2_LAST_ACTIVITY_DATE_FIELD_ID =
|
||||
'643ab4a2-b6f2-4d11-9173-6859884b8781';
|
||||
export const CLOUD_USER_2_PAGE_VIEWS_L24H_FIELD_ID =
|
||||
'294e95cb-13b3-4163-95f8-e4f31837cb47';
|
||||
export const CLOUD_USER_2_PAGE_VIEWS_L30D_FIELD_ID =
|
||||
'8dacb4f1-b4ef-42d6-a917-17c78d6273a7';
|
||||
export const CLOUD_USER_2_PAGE_VIEWS_L7D_FIELD_ID =
|
||||
'c7741332-d9b6-42ed-84e2-8afa184b58f3';
|
||||
export const CLOUD_USER_2_UPDATED_BY_FIELD_ID =
|
||||
'639c9a30-2926-44c3-ab44-100dbda91c64';
|
||||
export const CLOUD_USER_2_USER_TENURE_FIELD_ID =
|
||||
'0cc9ca63-c06f-4422-9325-5e99f98d05ed';
|
||||
export const CLOUD_USER_2_WORKSPACE_COUNT_FIELD_ID =
|
||||
'5af6ae62-b741-471b-90bb-7fb6678fc8c9';
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk';
|
||||
|
||||
import {
|
||||
CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID,
|
||||
CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID,
|
||||
} from 'src/fields/cloud-user-workspaces-on-cloud-user.field';
|
||||
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
|
||||
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID,
|
||||
objectUniversalIdentifier: CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'cloudUser2',
|
||||
label: 'Cloud User 2',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'cloudUser2Id',
|
||||
},
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
// Field metadata identifiers for cloudUserWorkspace2 object
|
||||
// These constants are used by both the object definition and views
|
||||
|
||||
export const CLOUD_USER_WORKSPACE_2_TWENTY_USER_IDENTIFIER_FIELD_ID =
|
||||
'ad149693-0df5-4346-90ff-39e6d945b90f';
|
||||
export const CLOUD_USER_WORKSPACE_2_TWENTY_WORKSPACE_IDENTIFIER_FIELD_ID =
|
||||
'c5d02b59-ebaf-4604-809c-ce3f6e50dedc';
|
||||
export const CLOUD_USER_WORKSPACE_2_ID_OF_THE_USER_WORKSPACE_FIELD_ID =
|
||||
'6a0f8018-be3c-49b0-bb91-8d3e6b44adec';
|
||||
export const CLOUD_USER_WORKSPACE_2_UPDATED_BY_FIELD_ID =
|
||||
'29d47f11-1e45-4c67-b495-fcfa45bb67e2';
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { defineField, FieldType, RelationType } from 'twenty-sdk';
|
||||
|
||||
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
|
||||
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
|
||||
|
||||
export const CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID =
|
||||
'2290d722-d4b1-47ab-a895-4e2c3163a541';
|
||||
export const CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID =
|
||||
'49b08cf9-15e1-4583-826f-943fe3c6b0e8';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID,
|
||||
objectUniversalIdentifier: CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'cloudUserWorkspaces2',
|
||||
label: 'Cloud User Workspaces 2',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { defineField, FieldType, RelationType } from 'twenty-sdk';
|
||||
|
||||
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
|
||||
import { CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-workspace-2';
|
||||
|
||||
export const CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID =
|
||||
'90d9dfc7-7058-4d1c-aac2-1505bd7cb827';
|
||||
export const CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID =
|
||||
'7bcd2dda-d1f8-4aa7-a83f-6cf240be80b2';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID,
|
||||
objectUniversalIdentifier: CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'cloudUserWorkspaces2',
|
||||
label: 'Cloud User Workspaces 2',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
|
||||
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
|
||||
|
||||
export const CLOUD_USERS_2_ON_PERSON_ID =
|
||||
'f238e91c-11d8-4a27-a39b-c9fa3515d4a0';
|
||||
export const PERSON_ON_CLOUD_USER_2_ID =
|
||||
'08d3aff0-7548-4a99-a097-20a0ad2c9ee7';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: CLOUD_USERS_2_ON_PERSON_ID,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'cloudUsers2',
|
||||
label: 'Cloud Users 2',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PERSON_ON_CLOUD_USER_2_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
// Field metadata identifiers for cloudWorkspace2 object
|
||||
// These constants are used by both the object definition and views
|
||||
|
||||
export const CLOUD_WORKSPACE_2_CUSTOM_DOMAIN_FIELD_ID =
|
||||
'c30799d5-adc0-49be-82a1-6d41b5aa9e82';
|
||||
export const CLOUD_WORKSPACE_2_WORKSPACE_BUSINESS_DOMAIN_FIELD_ID =
|
||||
'bfa2f91d-1c6c-48de-bb11-ac2a1e2545a9';
|
||||
export const CLOUD_WORKSPACE_2_COMPANY_LINKEDIN_FIELD_ID =
|
||||
'f9f2ef3a-8235-4d21-9a45-d29cc2124456';
|
||||
export const CLOUD_WORKSPACE_2_DATA_LAST_UPDATED_AT_FIELD_ID =
|
||||
'fbeaab89-77e0-4e13-97ed-caeb757a38c7';
|
||||
export const CLOUD_WORKSPACE_2_LAST_PAGE_VIEW_DATE_FIELD_ID =
|
||||
'7d3d4ba3-5af2-4248-afc7-7f4ce7c82805';
|
||||
export const CLOUD_WORKSPACE_2_TOTAL_EVER_ACTIVE_WORKSPACE_USERS_FIELD_ID =
|
||||
'56e99339-dcc4-4950-ae64-1b6ae326af2c';
|
||||
export const CLOUD_WORKSPACE_2_ACTIVE_USERS_L30D_FIELD_ID =
|
||||
'e9c703e6-9ffc-497d-9755-018724ac8d66';
|
||||
export const CLOUD_WORKSPACE_2_ACTIVE_USERS_L7D_FIELD_ID =
|
||||
'81d8f622-2366-481f-b0e8-b14fb7efa4d5';
|
||||
export const CLOUD_WORKSPACE_2_ACTIVE_USERS_L24H_FIELD_ID =
|
||||
'3e53d606-1afa-4d49-be62-52f4bb1cb49e';
|
||||
export const CLOUD_WORKSPACE_2_WORKSPACE_TENURE_FIELD_ID =
|
||||
'3fdd272c-73dd-432e-99c7-782f237a7cd8';
|
||||
export const CLOUD_WORKSPACE_2_PAGE_VIEWS_L30D_FIELD_ID =
|
||||
'1931f3cd-a984-41e3-b8f5-510f67b508e6';
|
||||
export const CLOUD_WORKSPACE_2_PAGE_VIEWS_L7D_FIELD_ID =
|
||||
'1f6a0ef6-3faf-4157-88a7-f624b7655c6b';
|
||||
export const CLOUD_WORKSPACE_2_PAGE_VIEWS_L24H_FIELD_ID =
|
||||
'ab0f46aa-9fef-40c6-a229-6ed4e05867d4';
|
||||
export const CLOUD_WORKSPACE_2_TOTAL_WORKSPACE_USERS_FIELD_ID =
|
||||
'38095859-2efb-4f40-86a1-aa883fd88e3d';
|
||||
export const CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_L30D_FIELD_ID =
|
||||
'4ebd8329-a18c-4606-9ee0-f32fb1c4aa6f';
|
||||
export const CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_TOTAL_FIELD_ID =
|
||||
'754f137f-b759-47da-8b57-7de71ad34d37';
|
||||
export const CLOUD_WORKSPACE_2_ALEXA_RANK_FIELD_ID =
|
||||
'1627e6b8-e87a-4048-b954-8d2fe09b67c8';
|
||||
export const CLOUD_WORKSPACE_2_EMPLOYEES_FIELD_ID =
|
||||
'1d8bb210-b50d-4b38-a611-d78ba865cbae';
|
||||
export const CLOUD_WORKSPACE_2_IS_ACTIVE_L7D_FIELD_ID =
|
||||
'42879662-d4fc-4806-bcfb-fdb586827491';
|
||||
export const CLOUD_WORKSPACE_2_IS_ACTIVE_L24H_FIELD_ID =
|
||||
'44b2c12f-aedd-459a-848a-ade9fc4620b7';
|
||||
export const CLOUD_WORKSPACE_2_IS_ACTIVE_L30D_FIELD_ID =
|
||||
'cac2d3e0-c2ee-4e22-bd96-4fa051a36f73';
|
||||
export const CLOUD_WORKSPACE_2_IS_ENRICHED_FIELD_ID =
|
||||
'45cbddfa-54cc-4d94-88d2-db7a7a550414';
|
||||
export const CLOUD_WORKSPACE_2_INDUSTRY_FIELD_ID =
|
||||
'a6686308-dfcf-4abb-be0f-e8f48cc579d9';
|
||||
export const CLOUD_WORKSPACE_2_COMPANY_FOUNDED_YEAR_FIELD_ID =
|
||||
'0432af7e-7575-486e-a022-f4a625a8ac14';
|
||||
export const CLOUD_WORKSPACE_2_DESCRIPTION_FIELD_ID =
|
||||
'40854f41-4175-4b14-9567-c6bfb3f5d441';
|
||||
export const CLOUD_WORKSPACE_2_LATEST_FUNDING_STAGE_FIELD_ID =
|
||||
'44aba339-be2e-4364-a894-cf730081c8a6';
|
||||
export const CLOUD_WORKSPACE_2_COMPANY_NAME_FIELD_ID =
|
||||
'f11ddfc2-3356-453a-a6e1-494b6c6b12f6';
|
||||
export const CLOUD_WORKSPACE_2_SUB_DOMAIN_FIELD_ID =
|
||||
'8fb18f52-44e9-4288-87c1-9650222efb05';
|
||||
export const CLOUD_WORKSPACE_2_ANNUAL_REVENUE_FIELD_ID =
|
||||
'8f5dfc54-213b-43b2-a502-631f0f3240d1';
|
||||
export const CLOUD_WORKSPACE_2_MRR_FIELD_ID =
|
||||
'67125b6c-ff41-459f-8153-a338ed11c4f0';
|
||||
export const CLOUD_WORKSPACE_2_POTENTIAL_ARR_FIELD_ID =
|
||||
'0c38b297-0e9d-47cc-8f8f-a0e5402413ce';
|
||||
export const CLOUD_WORKSPACE_2_ARR_FIELD_ID =
|
||||
'f932f94b-c48b-4ef3-9eea-0c8dad370d89';
|
||||
export const CLOUD_WORKSPACE_2_TOTAL_FUNDING_FIELD_ID =
|
||||
'ae5da0c9-e9ac-401b-ad48-c37a31c6ecd1';
|
||||
export const CLOUD_WORKSPACE_2_ACTIVATION_STATUS_FIELD_ID =
|
||||
'ef44daa9-d272-4680-a19c-caf2bac32b70';
|
||||
export const CLOUD_WORKSPACE_2_SUBSCRIPTION_STATUS_FIELD_ID =
|
||||
'a88e8cab-333f-44a3-980e-f2cc22871aee';
|
||||
export const CLOUD_WORKSPACE_2_PAYMENT_FREQUENCY_FIELD_ID =
|
||||
'fccc73da-2b6e-434b-921b-e3bcbab1c56c';
|
||||
export const CLOUD_WORKSPACE_2_NEXT_RENEWAL_DATE_FIELD_ID =
|
||||
'488b38b4-971e-4c33-b60d-7e7934911579';
|
||||
export const CLOUD_WORKSPACE_2_CREATOR_EMAIL_FIELD_ID =
|
||||
'3815b992-a919-4c52-8629-b4b5f89a6060';
|
||||
export const CLOUD_WORKSPACE_2_TAGS_FIELD_ID =
|
||||
'0f41485f-4868-4bd6-8d76-709a6b7a54e3';
|
||||
export const CLOUD_WORKSPACE_2_UPDATED_BY_FIELD_ID =
|
||||
'252cdff1-facb-4f66-8608-6138a585d099';
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk';
|
||||
|
||||
import {
|
||||
CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID,
|
||||
CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID,
|
||||
} from 'src/fields/cloud-user-workspaces-on-cloud-workspace.field';
|
||||
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
|
||||
import { CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-workspace-2';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID,
|
||||
objectUniversalIdentifier: CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'cloudWorkspace2',
|
||||
label: 'Cloud Workspace 2',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'cloudWorkspace2Id',
|
||||
},
|
||||
});
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
OnDeleteAction,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
|
||||
import {
|
||||
CLOUD_USERS_2_ON_PERSON_ID,
|
||||
PERSON_ON_CLOUD_USER_2_ID,
|
||||
} from 'src/fields/cloud-users-2-on-person.field';
|
||||
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PERSON_ON_CLOUD_USER_2_ID,
|
||||
objectUniversalIdentifier: CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'person',
|
||||
label: 'Person',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: CLOUD_USERS_2_ON_PERSON_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'personId',
|
||||
},
|
||||
});
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
import { syncCloudUserWorkspaces } from 'src/logic-functions/sync-product-data/sync-cloud-user-workspaces';
|
||||
import { syncCloudUsers } from 'src/logic-functions/sync-product-data/sync-cloud-users';
|
||||
import { syncCloudWorkspaces } from 'src/logic-functions/sync-product-data/sync-cloud-workspaces';
|
||||
|
||||
const handler = async (): Promise<{ message: string }> => {
|
||||
try {
|
||||
console.log('Starting product data sync');
|
||||
|
||||
const cloudUsersResult = await syncCloudUsers();
|
||||
|
||||
console.log(
|
||||
`Cloud users sync complete: ${cloudUsersResult.syncedCount} users`,
|
||||
);
|
||||
|
||||
const cloudWorkspacesResult = await syncCloudWorkspaces();
|
||||
|
||||
console.log(
|
||||
`Cloud workspaces sync complete: ${cloudWorkspacesResult.syncedCount} workspaces`,
|
||||
);
|
||||
|
||||
const cloudUserWorkspacesResult = await syncCloudUserWorkspaces();
|
||||
|
||||
console.log(
|
||||
`Cloud user workspaces sync complete: ${cloudUserWorkspacesResult.syncedCount} user workspaces`,
|
||||
);
|
||||
|
||||
return {
|
||||
message: `Product data sync complete — ${cloudUsersResult.syncedCount} users, ${cloudWorkspacesResult.syncedCount} workspaces, ${cloudUserWorkspacesResult.syncedCount} user workspaces`,
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '3897e059-715e-4a4b-b165-c44f17d2e30a',
|
||||
name: 'sync-product-data',
|
||||
description:
|
||||
'Syncs cloud users, cloud workspaces, and cloud user workspaces from ClickHouse',
|
||||
timeoutSeconds: 120,
|
||||
handler,
|
||||
cronTriggerSettings: {
|
||||
pattern: '*/10 * * * *',
|
||||
},
|
||||
});
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getApplicationConfig } from 'src/shared/application-config';
|
||||
import { fetchFromClickHouse } from 'src/shared/clickhouse-client';
|
||||
import { twentyClient } from 'src/shared/twenty-client';
|
||||
|
||||
const clickHouseUserWorkspaceSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
userId: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
deletedAt: z.string(),
|
||||
is_active_membership: z.coerce.boolean(),
|
||||
});
|
||||
|
||||
type ClickHouseUserWorkspace = z.infer<typeof clickHouseUserWorkspaceSchema>;
|
||||
|
||||
const fetchUserWorkspacesFromClickHouse = async (): Promise<
|
||||
ClickHouseUserWorkspace[]
|
||||
> => {
|
||||
const { clickHouseDatabase } = getApplicationConfig();
|
||||
|
||||
// const nowDate = 'now()'
|
||||
const nowDate = "'2026-02-04 14:28:52.000'";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
*
|
||||
FROM (
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (PARTITION BY id ORDER BY updatedAt DESC) AS rn
|
||||
FROM
|
||||
${clickHouseDatabase}.user_workspace
|
||||
WHERE
|
||||
updatedAt >= ${nowDate} - INTERVAL 500 MINUTE
|
||||
AND
|
||||
updatedAt <= ${nowDate}
|
||||
)
|
||||
WHERE
|
||||
rn = 1
|
||||
FORMAT
|
||||
JSONEachRow;
|
||||
`;
|
||||
|
||||
return fetchFromClickHouse(query, clickHouseUserWorkspaceSchema);
|
||||
};
|
||||
|
||||
const buildCloudUserWorkspaceInput = (
|
||||
userWorkspace: ClickHouseUserWorkspace,
|
||||
) => ({
|
||||
id: userWorkspace.id,
|
||||
twentyUserIdentifier: userWorkspace.userId,
|
||||
twentyWorkspaceIdentifier: userWorkspace.workspaceId,
|
||||
idOfTheUserWorkspace: userWorkspace.id,
|
||||
cloudUser2Id: userWorkspace.userId,
|
||||
cloudWorkspace2Id: userWorkspace.workspaceId,
|
||||
});
|
||||
|
||||
export const syncCloudUserWorkspaces = async (): Promise<{
|
||||
syncedCount: number;
|
||||
}> => {
|
||||
const userWorkspaces = await fetchUserWorkspacesFromClickHouse();
|
||||
|
||||
console.log(
|
||||
`Fetched ${userWorkspaces.length} user workspaces from ClickHouse`,
|
||||
);
|
||||
|
||||
if (userWorkspaces.length === 0) {
|
||||
return { syncedCount: 0 };
|
||||
}
|
||||
|
||||
const cloudUserWorkspaceInputs = userWorkspaces.map(
|
||||
buildCloudUserWorkspaceInput,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Batch-upserting ${cloudUserWorkspaceInputs.length} cloud user workspaces`,
|
||||
);
|
||||
|
||||
await twentyClient.mutation({
|
||||
createCloudUserWorkspaces2: {
|
||||
__args: {
|
||||
data: cloudUserWorkspaceInputs,
|
||||
upsert: true,
|
||||
},
|
||||
__scalar: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { syncedCount: userWorkspaces.length };
|
||||
};
|
||||
-268
@@ -1,268 +0,0 @@
|
||||
import { enumCloudUser2ActivityStatusEnum } from 'twenty-sdk/generated';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getApplicationConfig } from 'src/shared/application-config';
|
||||
import { fetchFromClickHouse } from 'src/shared/clickhouse-client';
|
||||
import { clickHouseDateToIso } from 'src/shared/clickhouse-date-to-iso';
|
||||
import { twentyClient } from 'src/shared/twenty-client';
|
||||
|
||||
const clickHouseUserSchema = z.object({
|
||||
userId: z.uuid(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
email: z.email(),
|
||||
fullName: z.string(),
|
||||
isEmailVerified: z.boolean(),
|
||||
disabled: z.boolean(),
|
||||
canImpersonate: z.boolean(),
|
||||
canAccessFullAdminPanel: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
deletedAt: z.string(),
|
||||
locale: z.string(),
|
||||
createdDate: z.string(),
|
||||
workspaceCount: z.coerce.number(),
|
||||
workspaceIds: z.string(),
|
||||
workspaceDomains: z.string(),
|
||||
firstActivityDate: z.string(),
|
||||
lastActivityDate: z.string(),
|
||||
lastWorkspaceId: z.string(),
|
||||
totalPageviews: z.coerce.number(),
|
||||
pageviewsLast30d: z.coerce.number(),
|
||||
pageviewsLast7d: z.coerce.number(),
|
||||
pageviewsLast24h: z.coerce.number(),
|
||||
userAgeDays: z.coerce.number(),
|
||||
daysSinceLastActivity: z.coerce.number(),
|
||||
isActiveLast30d: z.boolean(),
|
||||
isActiveLast7d: z.boolean(),
|
||||
isActiveLast24h: z.boolean(),
|
||||
activityStatus: z
|
||||
.string()
|
||||
.transform((val) => val.toUpperCase())
|
||||
.pipe(z.enum(enumCloudUser2ActivityStatusEnum)),
|
||||
avgDailyPageviewsLast30d: z.coerce.number(),
|
||||
isTwenty: z.coerce.boolean(),
|
||||
maxWorkspaceMembers: z.coerce.number(),
|
||||
inTrial: z.boolean(),
|
||||
});
|
||||
|
||||
type ClickHouseUser = z.infer<typeof clickHouseUserSchema>;
|
||||
|
||||
const fetchUsersFromClickHouse = async (): Promise<ClickHouseUser[]> => {
|
||||
const { clickHouseDatabase } = getApplicationConfig();
|
||||
|
||||
// const nowDate = 'now()'
|
||||
const nowDate = "'2026-02-04 14:28:52.000'";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
${clickHouseDatabase}.user
|
||||
WHERE
|
||||
lastActivityDate >= ${nowDate} - INTERVAL 500 MINUTE
|
||||
AND
|
||||
lastActivityDate <= ${nowDate}
|
||||
FORMAT
|
||||
JSONEachRow;
|
||||
`;
|
||||
|
||||
return fetchFromClickHouse(query, clickHouseUserSchema);
|
||||
};
|
||||
|
||||
const fetchAllPeopleFromTwentyByEmail = async (emails: string[]) => {
|
||||
if (emails.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allPeople = await twentyClient.query({
|
||||
people: {
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
emails: {
|
||||
primaryEmail: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
__args: {
|
||||
filter: {
|
||||
emails: {
|
||||
primaryEmail: {
|
||||
in: emails,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return allPeople.people?.edges.map((edge) => edge.node) ?? [];
|
||||
};
|
||||
|
||||
const buildCloudUserInput = ({
|
||||
user,
|
||||
personId,
|
||||
}: {
|
||||
user: ClickHouseUser;
|
||||
personId: string;
|
||||
}) => ({
|
||||
id: user.userId,
|
||||
name: user.fullName,
|
||||
email: {
|
||||
primaryEmail: user.email,
|
||||
},
|
||||
personId,
|
||||
fullName: {
|
||||
lastName: user.lastName,
|
||||
firstName: user.firstName,
|
||||
},
|
||||
isTwenty: user.isTwenty,
|
||||
userTenure: user.userAgeDays,
|
||||
isActiveL7d: user.isActiveLast7d,
|
||||
isActiveL24h: user.isActiveLast24h,
|
||||
isActiveL30d: user.isActiveLast30d,
|
||||
pageViewsL7d: user.pageviewsLast7d,
|
||||
pageViewsL24h: user.pageviewsLast24h,
|
||||
pageViewsL30d: user.pageviewsLast30d,
|
||||
activityStatus: user.activityStatus,
|
||||
workspaceCount: user.workspaceCount,
|
||||
lastActivityDate: clickHouseDateToIso(user.lastActivityDate),
|
||||
dataLastUpdatedAt: new Date().toISOString(),
|
||||
avgDailyPageviewsLast30d: user.avgDailyPageviewsLast30d,
|
||||
daysSinceLastActivity: user.daysSinceLastActivity,
|
||||
});
|
||||
|
||||
export const syncCloudUsers = async (): Promise<{
|
||||
syncedCount: number;
|
||||
}> => {
|
||||
const users = await fetchUsersFromClickHouse();
|
||||
|
||||
console.log('Fetched users from ClickHouse', users);
|
||||
|
||||
const emails = users.map((user) => user.email);
|
||||
|
||||
console.log('Fetching people from Twenty with emails', emails);
|
||||
|
||||
const people = await fetchAllPeopleFromTwentyByEmail(emails);
|
||||
|
||||
console.log('Fetched people from Twenty', people);
|
||||
|
||||
// Build a map of email -> personId from existing people
|
||||
const emailToPersonId = new Map<string, string>();
|
||||
|
||||
for (const person of people) {
|
||||
if (person.emails?.primaryEmail !== undefined) {
|
||||
emailToPersonId.set(person.emails.primaryEmail.toLowerCase(), person.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Partition users into those with/without an existing person
|
||||
const usersWithoutPerson = users.filter(
|
||||
(user) => !emailToPersonId.has(user.email.toLowerCase()),
|
||||
);
|
||||
|
||||
// Step 1: Batch-create missing people
|
||||
const newlyCreatedPersonIds: string[] = [];
|
||||
|
||||
if (usersWithoutPerson.length > 0) {
|
||||
console.log(`Batch-creating ${usersWithoutPerson.length} people records`);
|
||||
|
||||
const createPeopleResult = await twentyClient.mutation({
|
||||
createPeople: {
|
||||
__args: {
|
||||
data: usersWithoutPerson.map((user) => ({
|
||||
name: {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
},
|
||||
emails: {
|
||||
primaryEmail: user.email,
|
||||
},
|
||||
})),
|
||||
},
|
||||
id: true,
|
||||
emails: {
|
||||
primaryEmail: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdPeople = createPeopleResult.createPeople ?? [];
|
||||
|
||||
for (const person of createdPeople) {
|
||||
if (
|
||||
person.emails?.primaryEmail === undefined ||
|
||||
person.id === undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newlyCreatedPersonIds.push(person.id);
|
||||
emailToPersonId.set(person.emails.primaryEmail.toLowerCase(), person.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Batch-upsert all cloud users, with rollback on failure
|
||||
const cloudUserInputs = users.map((user) => {
|
||||
const personId = emailToPersonId.get(user.email.toLowerCase());
|
||||
|
||||
if (personId === undefined) {
|
||||
throw new Error(
|
||||
`No personId found for user ${user.email} — this should not happen`,
|
||||
);
|
||||
}
|
||||
|
||||
return buildCloudUserInput({ user, personId });
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(`Batch-upserting ${cloudUserInputs.length} cloud users`);
|
||||
|
||||
await twentyClient.mutation({
|
||||
createCloudUsers2: {
|
||||
__args: {
|
||||
data: cloudUserInputs,
|
||||
upsert: true,
|
||||
},
|
||||
__scalar: true,
|
||||
},
|
||||
});
|
||||
} catch (cloudUserError) {
|
||||
console.log(
|
||||
'Cloud user upsert failed, rolling back newly created people',
|
||||
cloudUserError,
|
||||
);
|
||||
|
||||
// Rollback: hard-delete people that were created in step 1
|
||||
if (newlyCreatedPersonIds.length > 0) {
|
||||
try {
|
||||
await twentyClient.mutation({
|
||||
destroyPeople: {
|
||||
__args: {
|
||||
filter: {
|
||||
id: {
|
||||
in: newlyCreatedPersonIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Rolled back ${newlyCreatedPersonIds.length} newly created people`,
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
console.log(
|
||||
'Rollback of newly created people also failed',
|
||||
rollbackError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw cloudUserError;
|
||||
}
|
||||
|
||||
return { syncedCount: users.length };
|
||||
};
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
import {
|
||||
enumCloudWorkspace2ActivationStatusEnum,
|
||||
enumCloudWorkspace2PaymentFrequencyEnum,
|
||||
enumCloudWorkspace2SubscriptionStatusEnum,
|
||||
} from 'twenty-sdk/generated';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getApplicationConfig } from 'src/shared/application-config';
|
||||
import { fetchFromClickHouse } from 'src/shared/clickhouse-client';
|
||||
import { clickHouseDateToIso } from 'src/shared/clickhouse-date-to-iso';
|
||||
import { twentyClient } from 'src/shared/twenty-client';
|
||||
|
||||
const clickHouseWorkspaceSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
workspaceName: z.string(),
|
||||
subdomain: z.string(),
|
||||
customDomain: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
deletedAt: z.string(),
|
||||
activationStatus: z
|
||||
.string()
|
||||
.transform((val) => {
|
||||
const upper = val.toUpperCase();
|
||||
if (upper === '') return 'EMPTY';
|
||||
const valid: string[] = Object.values(
|
||||
enumCloudWorkspace2ActivationStatusEnum,
|
||||
);
|
||||
return valid.includes(upper) ? upper : 'EMPTY';
|
||||
})
|
||||
.pipe(z.enum(enumCloudWorkspace2ActivationStatusEnum)),
|
||||
createdDate: z.string(),
|
||||
lastPageviewDate: z.string(),
|
||||
pageviewsLast30d: z.coerce.number(),
|
||||
pageviewsLast7d: z.coerce.number(),
|
||||
pageviewsLast24h: z.coerce.number(),
|
||||
totalEverActiveUsers: z.coerce.number(),
|
||||
totalWorkspaceUsers: z.coerce.number(),
|
||||
activeUsersLast30d: z.coerce.number(),
|
||||
activeUsersLast7d: z.coerce.number(),
|
||||
activeUsersLast24h: z.coerce.number(),
|
||||
isActiveLast30d: z.coerce.boolean(),
|
||||
isActiveLast7d: z.coerce.boolean(),
|
||||
isActiveLast24h: z.coerce.boolean(),
|
||||
activeUserRatioLast30d: z.coerce.number(),
|
||||
activeUserRatioLast7d: z.coerce.number(),
|
||||
workspaceAgeDays: z.coerce.number(),
|
||||
totalEvents: z.coerce.number(),
|
||||
eventsLast30d: z.coerce.number(),
|
||||
eventsPerUser: z.coerce.number(),
|
||||
subscription_status: z
|
||||
.string()
|
||||
.transform((val) => {
|
||||
const upper = val.toUpperCase();
|
||||
if (upper === '') return 'EMPTY';
|
||||
const valid: string[] = Object.values(
|
||||
enumCloudWorkspace2SubscriptionStatusEnum,
|
||||
);
|
||||
return valid.includes(upper) ? upper : 'OTHER';
|
||||
})
|
||||
.pipe(z.enum(enumCloudWorkspace2SubscriptionStatusEnum)),
|
||||
payment_frequency: z
|
||||
.string()
|
||||
.transform((val) => {
|
||||
const upper = val.toUpperCase();
|
||||
if (upper === '') return 'EMPTY';
|
||||
const valid: string[] = Object.values(
|
||||
enumCloudWorkspace2PaymentFrequencyEnum,
|
||||
);
|
||||
return valid.includes(upper) ? upper : 'OTHER';
|
||||
})
|
||||
.pipe(z.enum(enumCloudWorkspace2PaymentFrequencyEnum)),
|
||||
trial_status: z.string(),
|
||||
mrr: z.coerce.number(),
|
||||
potential_arr: z.coerce.number(),
|
||||
arr: z.coerce.number().nullable(),
|
||||
next_renewal_date: z.string(),
|
||||
workspace_domain: z.string(),
|
||||
domain_source: z.string(),
|
||||
creatorUserId: z.string(),
|
||||
creatorEmail: z.string(),
|
||||
creator_domain_type: z.string(),
|
||||
primary_business_domain: z.string(),
|
||||
business_domain_user_count: z.coerce.number(),
|
||||
isTwenty: z.coerce.boolean(),
|
||||
});
|
||||
|
||||
type ClickHouseWorkspace = z.infer<typeof clickHouseWorkspaceSchema>;
|
||||
|
||||
const fetchWorkspacesFromClickHouse =
|
||||
async (): Promise<ClickHouseWorkspace[]> => {
|
||||
const { clickHouseDatabase } = getApplicationConfig();
|
||||
|
||||
// const nowDate = 'now()'
|
||||
const nowDate = "'2026-02-04 14:28:52.000'";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
*
|
||||
FROM (
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (PARTITION BY workspaceId ORDER BY updatedAt DESC) AS rn
|
||||
FROM
|
||||
${clickHouseDatabase}.workspace
|
||||
WHERE
|
||||
updatedAt >= ${nowDate} - INTERVAL 500 MINUTE
|
||||
AND
|
||||
updatedAt <= ${nowDate}
|
||||
)
|
||||
WHERE
|
||||
rn = 1
|
||||
FORMAT
|
||||
JSONEachRow;
|
||||
`;
|
||||
|
||||
return fetchFromClickHouse(query, clickHouseWorkspaceSchema);
|
||||
};
|
||||
|
||||
// Convert a dollar amount to micros (1 USD = 1_000_000 micros).
|
||||
const dollarsToAmountMicros = (dollars: number) =>
|
||||
Math.round(dollars * 1_000_000);
|
||||
|
||||
const buildCloudWorkspaceInput = (workspace: ClickHouseWorkspace) => ({
|
||||
id: workspace.workspaceId,
|
||||
name: workspace.workspaceName,
|
||||
subDomain: workspace.subdomain,
|
||||
customDomain: {
|
||||
primaryLinkUrl: workspace.customDomain,
|
||||
primaryLinkLabel: '',
|
||||
},
|
||||
activationStatus: workspace.activationStatus,
|
||||
subscriptionStatus: workspace.subscription_status,
|
||||
paymentFrequency: workspace.payment_frequency,
|
||||
lastPageViewDate: workspace.lastPageviewDate,
|
||||
pageViewsL30D: workspace.pageviewsLast30d,
|
||||
pageViewsL7D: workspace.pageviewsLast7d,
|
||||
pageViewsL24H: workspace.pageviewsLast24h,
|
||||
totalEverActiveWorkspaceUsers: workspace.totalEverActiveUsers,
|
||||
totalWorkspaceUsers: workspace.totalWorkspaceUsers,
|
||||
activeUsersL30D: workspace.activeUsersLast30d,
|
||||
activeUsersL7D: workspace.activeUsersLast7d,
|
||||
activeUsersL24H: workspace.activeUsersLast24h,
|
||||
isActiveL30D: workspace.isActiveLast30d,
|
||||
isActiveL7D: workspace.isActiveLast7d,
|
||||
isActiveL24H: workspace.isActiveLast24h,
|
||||
workspaceTenure: workspace.workspaceAgeDays,
|
||||
numberOfEventsTotal: workspace.totalEvents,
|
||||
numberOfEventsL30D: workspace.eventsLast30d,
|
||||
mrr: {
|
||||
amountMicros: dollarsToAmountMicros(workspace.mrr),
|
||||
currencyCode: 'USD',
|
||||
},
|
||||
potentialArr: {
|
||||
amountMicros: dollarsToAmountMicros(workspace.potential_arr),
|
||||
currencyCode: 'USD',
|
||||
},
|
||||
arr: {
|
||||
amountMicros: dollarsToAmountMicros(workspace.arr ?? 0),
|
||||
currencyCode: 'USD',
|
||||
},
|
||||
nextRenewalDate: clickHouseDateToIso(workspace.next_renewal_date),
|
||||
creatorEmail: {
|
||||
primaryEmail: workspace.creatorEmail,
|
||||
},
|
||||
workspaceBusinessDomain: {
|
||||
primaryLinkUrl: workspace.primary_business_domain,
|
||||
primaryLinkLabel: '',
|
||||
},
|
||||
dataLastUpdatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
export const syncCloudWorkspaces = async (): Promise<{
|
||||
syncedCount: number;
|
||||
}> => {
|
||||
const workspaces = await fetchWorkspacesFromClickHouse();
|
||||
|
||||
console.log(`Fetched ${workspaces.length} workspaces from ClickHouse`);
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
return { syncedCount: 0 };
|
||||
}
|
||||
|
||||
const cloudWorkspaceInputs = workspaces.map(buildCloudWorkspaceInput);
|
||||
|
||||
console.log(
|
||||
`Batch-upserting ${cloudWorkspaceInputs.length} cloud workspaces`,
|
||||
);
|
||||
|
||||
await twentyClient.mutation({
|
||||
createCloudWorkspaces2: {
|
||||
__args: {
|
||||
data: cloudWorkspaceInputs,
|
||||
upsert: true,
|
||||
},
|
||||
__scalar: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { syncedCount: workspaces.length };
|
||||
};
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { defineNavigationMenuItem } from 'twenty-sdk';
|
||||
|
||||
import { ALL_CLOUD_USER_2_VIEW_ID } from 'src/views/all-cloud-user-2';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'ac6df084-0f0f-404a-b3d1-b084cb624d76',
|
||||
name: 'cloud-user-2',
|
||||
icon: 'IconList',
|
||||
position: 0,
|
||||
viewUniversalIdentifier: ALL_CLOUD_USER_2_VIEW_ID,
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { defineNavigationMenuItem } from 'twenty-sdk';
|
||||
|
||||
import { ALL_CLOUD_USER_WORKSPACE_2_VIEW_ID } from 'src/views/all-cloud-user-workspace-2';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'e29251f0-78ad-493f-8f99-ec54ab0b5378',
|
||||
name: 'cloud-user-workspace-2',
|
||||
icon: 'IconList',
|
||||
position: 0,
|
||||
viewUniversalIdentifier: ALL_CLOUD_USER_WORKSPACE_2_VIEW_ID,
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { defineNavigationMenuItem } from 'twenty-sdk';
|
||||
|
||||
import { ALL_CLOUD_WORKSPACE_2_VIEW_ID } from 'src/views/all-cloud-workspace-2';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '0ba6315e-0d79-47a5-8c05-29dec621a647',
|
||||
name: 'cloud-workspace-2',
|
||||
icon: 'IconList',
|
||||
position: 0,
|
||||
viewUniversalIdentifier: ALL_CLOUD_WORKSPACE_2_VIEW_ID,
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
import {
|
||||
CLOUD_USER_2_ACTIVITY_STATUS_FIELD_ID,
|
||||
CLOUD_USER_2_AVG_DAILY_PAGEVIEWS_LAST_30D_FIELD_ID,
|
||||
CLOUD_USER_2_DATA_LAST_UPDATED_AT_FIELD_ID,
|
||||
CLOUD_USER_2_DAYS_SINCE_LAST_ACTIVITY_FIELD_ID,
|
||||
CLOUD_USER_2_EMAIL_FIELD_ID,
|
||||
CLOUD_USER_2_FULL_NAME_FIELD_ID,
|
||||
CLOUD_USER_2_IS_ACTIVE_L24H_FIELD_ID,
|
||||
CLOUD_USER_2_IS_ACTIVE_L30D_FIELD_ID,
|
||||
CLOUD_USER_2_IS_ACTIVE_L7D_FIELD_ID,
|
||||
CLOUD_USER_2_IS_TWENTY_FIELD_ID,
|
||||
CLOUD_USER_2_LAST_ACTIVITY_DATE_FIELD_ID,
|
||||
CLOUD_USER_2_PAGE_VIEWS_L24H_FIELD_ID,
|
||||
CLOUD_USER_2_PAGE_VIEWS_L30D_FIELD_ID,
|
||||
CLOUD_USER_2_PAGE_VIEWS_L7D_FIELD_ID,
|
||||
CLOUD_USER_2_UPDATED_BY_FIELD_ID,
|
||||
CLOUD_USER_2_USER_TENURE_FIELD_ID,
|
||||
CLOUD_USER_2_WORKSPACE_COUNT_FIELD_ID,
|
||||
} from 'src/fields/cloud-user-2-field-ids';
|
||||
|
||||
export const CLOUD_USER_2_UNIVERSAL_IDENTIFIER =
|
||||
'da264c72-df22-49b3-98e3-21cf6013e671';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'cloudUser2',
|
||||
namePlural: 'cloudUsers2',
|
||||
labelSingular: 'Cloud user 2',
|
||||
labelPlural: 'Cloud users 2',
|
||||
icon: 'IconBox',
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_ACTIVITY_STATUS_FIELD_ID,
|
||||
type: FieldType.SELECT,
|
||||
name: 'activityStatus',
|
||||
label: 'Activity Status',
|
||||
options: [
|
||||
{
|
||||
id: '141d21f3-71b0-4b37-a3d2-5d91d43d0493',
|
||||
value: 'ACTIVE',
|
||||
label: 'Active',
|
||||
position: 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'adb1d290-750f-468e-99ee-ed892bcb8974',
|
||||
value: 'RECENT',
|
||||
label: 'Recent',
|
||||
position: 1,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: '96f24b75-a671-4727-a78f-5a72419370ea',
|
||||
value: 'DORMANT',
|
||||
label: 'Dormant',
|
||||
position: 2,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: '88ddbef9-16a8-4643-9726-5b271cd477fa',
|
||||
value: 'INACTIVE',
|
||||
label: 'Inactive',
|
||||
position: 3,
|
||||
color: 'blue',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_AVG_DAILY_PAGEVIEWS_LAST_30D_FIELD_ID,
|
||||
type: FieldType.NUMBER,
|
||||
name: 'avgDailyPageviewsLast30d',
|
||||
label: 'Avg Daily Page Views',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_DATA_LAST_UPDATED_AT_FIELD_ID,
|
||||
type: FieldType.DATE_TIME,
|
||||
name: 'dataLastUpdatedAt',
|
||||
label: 'Data last updated at',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_DAYS_SINCE_LAST_ACTIVITY_FIELD_ID,
|
||||
type: FieldType.NUMBER,
|
||||
name: 'daysSinceLastActivity',
|
||||
label: 'Days Since Last Activity',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_EMAIL_FIELD_ID,
|
||||
type: FieldType.EMAILS,
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_FULL_NAME_FIELD_ID,
|
||||
type: FieldType.FULL_NAME,
|
||||
name: 'fullName',
|
||||
label: 'Full Name',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_IS_ACTIVE_L24H_FIELD_ID,
|
||||
type: FieldType.BOOLEAN,
|
||||
name: 'isActiveL24h',
|
||||
label: 'Is Active L24H',
|
||||
defaultValue: true
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_IS_ACTIVE_L30D_FIELD_ID,
|
||||
type: FieldType.BOOLEAN,
|
||||
name: 'isActiveL30d',
|
||||
label: 'Is Active L30D',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_IS_ACTIVE_L7D_FIELD_ID,
|
||||
type: FieldType.BOOLEAN,
|
||||
name: 'isActiveL7d',
|
||||
label: 'Is Active L7D',
|
||||
defaultValue: true
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_IS_TWENTY_FIELD_ID,
|
||||
type: FieldType.BOOLEAN,
|
||||
name: 'isTwenty',
|
||||
label: 'Is Twenty',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_LAST_ACTIVITY_DATE_FIELD_ID,
|
||||
type: FieldType.DATE_TIME,
|
||||
name: 'lastActivityDate',
|
||||
label: 'Last Activity Date',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_PAGE_VIEWS_L24H_FIELD_ID,
|
||||
type: FieldType.NUMBER,
|
||||
name: 'pageViewsL24h',
|
||||
label: 'Page Views L24H',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_PAGE_VIEWS_L30D_FIELD_ID,
|
||||
type: FieldType.NUMBER,
|
||||
name: 'pageViewsL30d',
|
||||
label: 'Page Views L30D',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_PAGE_VIEWS_L7D_FIELD_ID,
|
||||
type: FieldType.NUMBER,
|
||||
name: 'pageViewsL7d',
|
||||
label: 'Page Views L7D',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_UPDATED_BY_FIELD_ID,
|
||||
type: FieldType.ACTOR,
|
||||
name: 'updatedBy',
|
||||
label: 'Updated by',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_USER_TENURE_FIELD_ID,
|
||||
type: FieldType.NUMBER,
|
||||
name: 'userTenure',
|
||||
label: 'User tenure',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CLOUD_USER_2_WORKSPACE_COUNT_FIELD_ID,
|
||||
type: FieldType.NUMBER,
|
||||
name: 'workspaceCount',
|
||||
label: 'Workspace count',
|
||||
},
|
||||
],
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user