Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa04cc380 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ This directory contains Twenty's development guidelines and best practices in th
|
||||
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
|
||||
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
|
||||
- **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files)
|
||||
- **creating-syncable-entity.mdc** - Comprehensive guide for creating new syncable entities (with universalIdentifier and applicationId) in the workspace migration system (Agent-requested for metadata-modules and workspace-migration files)
|
||||
|
||||
### Code Quality
|
||||
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
|
||||
@@ -40,7 +39,6 @@ You can manually reference any rule using the `@ruleName` syntax:
|
||||
- `@nx-rules` - Include Nx-specific guidance
|
||||
- `@react-general-guidelines` - Load React best practices
|
||||
- `@testing-guidelines` - Get testing recommendations
|
||||
- `@creating-syncable-entity` - Guide for creating new syncable entities
|
||||
|
||||
### Rule Types Used
|
||||
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
|
||||
@@ -55,12 +53,10 @@ You can manually reference any rule using the `@ruleName` syntax:
|
||||
# Testing
|
||||
npx nx test twenty-front # Run unit tests
|
||||
npx nx storybook:build twenty-front # Build Storybook
|
||||
npx nx storybook:test # Run Storybook tests
|
||||
npx nx storybook:serve-and-test:static # Run Storybook tests
|
||||
|
||||
# Development
|
||||
npx nx lint:diff-with-main twenty-front # Lint files changed vs main (fastest)
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
|
||||
npx nx lint twenty-front # Lint all files (slower)
|
||||
npx nx lint twenty-front # Run linter
|
||||
npx nx typecheck twenty-front # Type checking
|
||||
npx nx run twenty-front:graphql:generate # Generate GraphQL types
|
||||
```
|
||||
@@ -74,9 +70,7 @@ npx nx run twenty-server:database:migrate:prod # Run migrations
|
||||
|
||||
# Development
|
||||
npx nx run twenty-server:start # Start the server
|
||||
npx nx lint:diff-with-main twenty-server # Lint files changed vs main (fastest)
|
||||
npx nx lint:diff-with-main twenty-server --configuration=fix # Auto-fix changed files
|
||||
npx nx run twenty-server:lint # Lint all files (slower)
|
||||
npx nx run twenty-server:lint # Run linter (add --fix to auto-fix)
|
||||
npx nx run twenty-server:typecheck # Type checking
|
||||
npx nx run twenty-server:test # Run unit tests
|
||||
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
---
|
||||
description: Main guide for creating syncable entities in Twenty's workspace migration system
|
||||
globs: ["**/metadata-modules/**", "**/workspace-migration/**"]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Creating a New Syncable Entity - Main Guide
|
||||
|
||||
This is the main guide for creating **syncable entities** in Twenty's workspace migration architecture.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
This main guide provides a high-level overview and navigation hub.
|
||||
|
||||
**⚡ Skills** (`.cursor/skills/syncable-entity-*/SKILL.md`) - Concise, action-oriented implementation guides for each step. Reference these when creating a new syncable entity.
|
||||
|
||||
**When to use:**
|
||||
- Start here for architecture overview and workflow
|
||||
- Reference specific skills (`@syncable-entity-types-and-constants`) when implementing each step
|
||||
|
||||
## What is a Syncable Entity?
|
||||
|
||||
A syncable entity is a metadata entity that:
|
||||
- Has a **`universalIdentifier`**: A unique identifier used for syncing entities across workspaces/applications
|
||||
- Has an **`applicationId`**: Links the entity to an application (Twenty Standard or Custom applications)
|
||||
- Participates in the **workspace migration system**: Can be created, updated, and deleted through the migration pipeline
|
||||
- Is **cached as a flat entity**: Denormalized representation for efficient validation and change detection
|
||||
|
||||
Examples: `skill`, `agent`, `view`, `viewField`, `role`, `pageLayout`, etc.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Input DTO → Transform → Universal Flat Entity → Builder/Validator → Runner → Database
|
||||
↓
|
||||
Cache Service
|
||||
```
|
||||
|
||||
**Key Components:**
|
||||
- **TypeORM Entity**: Database model extending `SyncableEntity`
|
||||
- **Flat Entity**: Denormalized type (no relations, dates as strings) - for caching
|
||||
- **Universal Flat Entity**: Flat entity with foreign keys mapped to universal identifiers - for migrations
|
||||
- **Transform Utils**: Convert DTOs to universal flat entities
|
||||
- **Builder/Validator**: Validate and create migration actions
|
||||
- **Runner**: Execute actions against the database
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
Follow these skills in order:
|
||||
|
||||
### 1️⃣ **Foundation: Types & Constants** → `@syncable-entity-types-and-constants`
|
||||
|
||||
**What:** Define all types, entities, and register in central constants
|
||||
|
||||
**Tasks:**
|
||||
- Create TypeORM entity (extends `SyncableEntity`)
|
||||
- Define flat entity types
|
||||
- Define action types (universal + flat)
|
||||
- Register in 5 central constants
|
||||
|
||||
**Why first:** Everything else depends on these types
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ **Data Layer: Cache & Transform** → `@syncable-entity-cache-and-transform`
|
||||
|
||||
**What:** Handle conversion between different representations
|
||||
|
||||
**Tasks:**
|
||||
- Create cache service
|
||||
- Create entity-to-flat conversion
|
||||
- Create input transform utils
|
||||
- Handle foreign key resolution
|
||||
|
||||
**Dependencies:** Requires Step 1
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ **Business Logic: Builder & Validation** → `@syncable-entity-builder-and-validation`
|
||||
|
||||
**What:** Validate business rules and create actions
|
||||
|
||||
**Tasks:**
|
||||
- Create validator service (never throws, never mutates)
|
||||
- Create builder service
|
||||
- Wire into orchestrator (⚠️ critical!)
|
||||
|
||||
**Dependencies:** Requires Steps 1-2
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ **Execution: Runner & Actions** → `@syncable-entity-runner-and-actions`
|
||||
|
||||
**What:** Execute migration actions against the database
|
||||
|
||||
**Tasks:**
|
||||
- Create action handlers (create/update/delete)
|
||||
- Implement transpilation methods
|
||||
- Create universal-to-flat conversion utilities
|
||||
|
||||
**Dependencies:** Requires Steps 1-3
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ **Assembly: Integration** → `@syncable-entity-integration`
|
||||
|
||||
**What:** Wire everything together
|
||||
|
||||
**Tasks:**
|
||||
- Register in 3 NestJS modules
|
||||
- Create service and resolver layers
|
||||
- Use exception interceptor
|
||||
|
||||
**Dependencies:** Requires Steps 1-4
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ **Testing: Integration Tests** (**MANDATORY**) → `@syncable-entity-testing`
|
||||
|
||||
**What:** Comprehensive test suite
|
||||
|
||||
**Tasks:**
|
||||
- Create test utilities
|
||||
- Write failing tests (all validator exceptions)
|
||||
- Write successful tests (all CRUD operations)
|
||||
- Use snapshot testing
|
||||
|
||||
**Dependencies:** Requires all previous steps
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Multi-Agent Workflow
|
||||
|
||||
For parallel development:
|
||||
1. **Agent 1** (Foundation): Complete Step 1 first - unblocks everyone
|
||||
2. **Agent 2** (Cache): Can start immediately after Step 1
|
||||
3. **Agent 3** (Builder): Can work in parallel with Agent 4 after Step 1
|
||||
4. **Agent 4** (Runner): Can work in parallel with Agent 3 after Step 1
|
||||
5. **Agent 5** (Integration): Assembles everything after Steps 2-4
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
| Layer | Responsibility | Can Throw? | Can Mutate? |
|
||||
|-------|---------------|------------|-------------|
|
||||
| Transform Utils | Data transformation | Yes (input validation) | N/A (creates new) |
|
||||
| Validator | Business rule validation | **No** (returns errors) | **No** |
|
||||
| Builder | Action creation | **No** (returns errors) | **No** |
|
||||
| Runner | Database operations | Yes (DB errors) | Yes (via TypeORM) |
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
⚠️ **Most Commonly Forgotten:**
|
||||
1. Wiring builder in orchestrator service
|
||||
2. Registering in all 3 modules (builder, validators, action handlers)
|
||||
3. Setting `universalIdentifier` correctly in entity-to-flat conversion
|
||||
|
||||
⚠️ **Common Mistakes:**
|
||||
1. Using regular IDs instead of universal identifiers in transform utils
|
||||
2. Throwing exceptions in validators/builders
|
||||
3. Mutating entity maps in validators/builders
|
||||
4. Forgetting to handle JSONB properties with `SerializedRelation`
|
||||
|
||||
### File Locations
|
||||
|
||||
```
|
||||
packages/twenty-shared/src/metadata/
|
||||
└── all-metadata-name.constant.ts
|
||||
|
||||
packages/twenty-server/src/engine/metadata-modules/
|
||||
├── my-entity/ # Step 1
|
||||
│ └── entities/
|
||||
├── flat-my-entity/ # Steps 1-2
|
||||
│ ├── types/
|
||||
│ ├── constants/
|
||||
│ ├── services/
|
||||
│ └── utils/
|
||||
└── flat-entity/constant/ # Step 1 (central registries)
|
||||
├── all-entity-properties-configuration-by-metadata-name.constant.ts
|
||||
├── all-one-to-many-metadata-relations.constant.ts
|
||||
├── all-many-to-one-metadata-foreign-key.constant.ts
|
||||
└── all-many-to-one-metadata-relations.constant.ts
|
||||
|
||||
packages/twenty-server/src/engine/workspace-manager/workspace-migration/
|
||||
├── workspace-migration-builder/ # Step 3
|
||||
│ ├── builders/my-entity/
|
||||
│ └── validators/services/
|
||||
└── workspace-migration-runner/ # Step 4
|
||||
└── action-handlers/my-entity/
|
||||
```
|
||||
|
||||
### Complete Checklist
|
||||
|
||||
Before considering complete:
|
||||
- [ ] All 6 guides completed
|
||||
- [ ] TypeORM entity extends `SyncableEntity`
|
||||
- [ ] All constants registered (5 central registries)
|
||||
- [ ] Cache service with correct decorator
|
||||
- [ ] Transform utils return universal flat entities
|
||||
- [ ] Validator never throws/mutates
|
||||
- [ ] Builder wired in orchestrator (⚠️ critical!)
|
||||
- [ ] All 3 action handlers implemented
|
||||
- [ ] All 3 modules updated
|
||||
- [ ] **Integration tests written (MANDATORY)**
|
||||
- [ ] **All failing scenarios covered**
|
||||
- [ ] **All successful use cases tested**
|
||||
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
|
||||
Reference the appropriate skill for step-by-step guidance:
|
||||
- `@syncable-entity-types-and-constants` - Types, entities, constants
|
||||
- `@syncable-entity-cache-and-transform` - Cache & transform
|
||||
- `@syncable-entity-builder-and-validation` - Builder & validation
|
||||
- `@syncable-entity-runner-and-actions` - Runner & actions
|
||||
- `@syncable-entity-integration` - Integration & wiring
|
||||
- `@syncable-entity-testing` - Testing patterns
|
||||
@@ -12,12 +12,7 @@ alwaysApply: true
|
||||
npx nx run twenty-front:build
|
||||
npx nx run twenty-server:test
|
||||
|
||||
# Lint diff with main (recommended - much faster!)
|
||||
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
|
||||
npx nx lint:diff-with-main twenty-server
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
|
||||
|
||||
# Run target for all projects (slower)
|
||||
# Run target for all projects
|
||||
npx nx run-many --target=build --all
|
||||
npx nx run-many --target=test --projects=twenty-front,twenty-server
|
||||
|
||||
@@ -48,18 +43,12 @@ npx nx g @nx/react:component my-component
|
||||
}
|
||||
```
|
||||
|
||||
## Linting Strategy
|
||||
For faster development, always prefer linting only changed files:
|
||||
- Use `npx nx lint:diff-with-main <project>` to lint only files changed vs main branch
|
||||
- Use `--configuration=fix` to auto-fix issues in changed files
|
||||
- Only use `npx nx lint <project>` when you need to lint the entire project
|
||||
|
||||
## Dependency Graph
|
||||
```bash
|
||||
# View project dependencies
|
||||
npx nx graph
|
||||
|
||||
# Check what's affected by changes (runs target on affected projects)
|
||||
# Check what's affected by changes
|
||||
npx nx affected --target=test
|
||||
npx nx affected --target=build --base=main
|
||||
```
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
---
|
||||
name: syncable-entity-builder-and-validation
|
||||
description: Create validation logic and migration action builders for syncable entities in Twenty. Use when implementing business rule validation, uniqueness checks, foreign key validation, or building workspace migration actions for syncable entities. Validators never throw and never mutate.
|
||||
---
|
||||
|
||||
# Syncable Entity: Builder & Validation (Step 3/6)
|
||||
|
||||
**Purpose**: Implement business rule validation and create migration action builders.
|
||||
|
||||
**When to use**: After completing Steps 1-2 (Types, Cache, Transform). Required before implementing action handlers.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
This step creates:
|
||||
1. Validator service (business logic validation)
|
||||
2. Builder service (action creation)
|
||||
3. Orchestrator wiring (**CRITICAL** - often forgotten!)
|
||||
|
||||
**Key principles**:
|
||||
- Validators **never throw** - return error arrays
|
||||
- Validators **never mutate** - pass optimistic entity maps
|
||||
- Use indexed lookups (O(1)) not `Object.values().find()` (O(n))
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Validator Service
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service.ts`
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { t, msg } from '@lingui/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
||||
import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type';
|
||||
import { WorkspaceMigrationValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/types/workspace-migration-validation-error.type';
|
||||
import { MyEntityExceptionCode } from 'src/engine/metadata-modules/my-entity/exceptions/my-entity-exception-code.enum';
|
||||
|
||||
@Injectable()
|
||||
export class FlatMyEntityValidatorService {
|
||||
validateMyEntityForCreate(
|
||||
flatMyEntity: FlatMyEntity,
|
||||
optimisticFlatMyEntityMaps: FlatMyEntityMaps,
|
||||
): WorkspaceMigrationValidationError[] {
|
||||
const errors: WorkspaceMigrationValidationError[] = [];
|
||||
|
||||
// Pattern 1: Required field validation
|
||||
if (!isDefined(flatMyEntity.name) || flatMyEntity.name.trim() === '') {
|
||||
errors.push({
|
||||
code: MyEntityExceptionCode.NAME_REQUIRED,
|
||||
message: t`Name is required`,
|
||||
userFriendlyMessage: msg`Please provide a name for this entity`,
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern 2: Uniqueness check - use indexed map (O(1))
|
||||
const existingEntityWithName = optimisticFlatMyEntityMaps.byName[flatMyEntity.name];
|
||||
|
||||
if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) {
|
||||
errors.push({
|
||||
code: MyEntityExceptionCode.MY_ENTITY_ALREADY_EXISTS,
|
||||
message: t`Entity with name ${flatMyEntity.name} already exists`,
|
||||
userFriendlyMessage: msg`An entity with this name already exists`,
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern 3: Foreign key validation
|
||||
if (isDefined(flatMyEntity.parentEntityId)) {
|
||||
const parentEntity = optimisticFlatParentEntityMaps.byId[flatMyEntity.parentEntityId];
|
||||
|
||||
if (!isDefined(parentEntity)) {
|
||||
errors.push({
|
||||
code: MyEntityExceptionCode.PARENT_ENTITY_NOT_FOUND,
|
||||
message: t`Parent entity with ID ${flatMyEntity.parentEntityId} not found`,
|
||||
userFriendlyMessage: msg`The specified parent entity does not exist`,
|
||||
});
|
||||
} else if (isDefined(parentEntity.deletedAt)) {
|
||||
errors.push({
|
||||
code: MyEntityExceptionCode.PARENT_ENTITY_DELETED,
|
||||
message: t`Parent entity is deleted`,
|
||||
userFriendlyMessage: msg`Cannot reference a deleted parent entity`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 4: Standard entity protection
|
||||
if (flatMyEntity.isCustom === false) {
|
||||
errors.push({
|
||||
code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_CREATED,
|
||||
message: t`Cannot create standard entity`,
|
||||
userFriendlyMessage: msg`Standard entities can only be created by the system`,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
validateMyEntityForUpdate(
|
||||
flatMyEntity: FlatMyEntity,
|
||||
updates: Partial<FlatMyEntity>,
|
||||
optimisticFlatMyEntityMaps: FlatMyEntityMaps,
|
||||
): WorkspaceMigrationValidationError[] {
|
||||
const errors: WorkspaceMigrationValidationError[] = [];
|
||||
|
||||
// Standard entity protection
|
||||
if (flatMyEntity.isCustom === false) {
|
||||
errors.push({
|
||||
code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_UPDATED,
|
||||
message: t`Cannot update standard entity`,
|
||||
userFriendlyMessage: msg`Standard entities cannot be modified`,
|
||||
});
|
||||
return errors; // Early return if standard
|
||||
}
|
||||
|
||||
// Uniqueness check for name changes
|
||||
if (isDefined(updates.name) && updates.name !== flatMyEntity.name) {
|
||||
const existingEntityWithName = optimisticFlatMyEntityMaps.byName[updates.name];
|
||||
|
||||
if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) {
|
||||
errors.push({
|
||||
code: MyEntityExceptionCode.MY_ENTITY_ALREADY_EXISTS,
|
||||
message: t`Entity with name ${updates.name} already exists`,
|
||||
userFriendlyMessage: msg`An entity with this name already exists`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
validateMyEntityForDelete(
|
||||
flatMyEntity: FlatMyEntity,
|
||||
): WorkspaceMigrationValidationError[] {
|
||||
const errors: WorkspaceMigrationValidationError[] = [];
|
||||
|
||||
// Standard entity protection
|
||||
if (flatMyEntity.isCustom === false) {
|
||||
errors.push({
|
||||
code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_DELETED,
|
||||
message: t`Cannot delete standard entity`,
|
||||
userFriendlyMessage: msg`Standard entities cannot be deleted`,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Performance warning**: Avoid `Object.values().find()` - use indexed maps instead!
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: O(n) - slow for large datasets
|
||||
const duplicate = Object.values(optimisticFlatMyEntityMaps.byId).find(
|
||||
(entity) => entity.name === flatMyEntity.name && entity.id !== flatMyEntity.id
|
||||
);
|
||||
|
||||
// ✅ GOOD: O(1) - use indexed map
|
||||
const existingEntityWithName = optimisticFlatMyEntityMaps.byName[flatMyEntity.name];
|
||||
if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) {
|
||||
// Handle duplicate
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create Builder Service
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service.ts`
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-entity-migration-builder.service';
|
||||
import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service';
|
||||
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
|
||||
import {
|
||||
type UniversalCreateMyEntityAction,
|
||||
type UniversalUpdateMyEntityAction,
|
||||
type UniversalDeleteMyEntityAction,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMigrationMyEntityActionsBuilderService extends WorkspaceEntityMigrationBuilderService<
|
||||
'myEntity',
|
||||
UniversalFlatMyEntity,
|
||||
UniversalCreateMyEntityAction,
|
||||
UniversalUpdateMyEntityAction,
|
||||
UniversalDeleteMyEntityAction
|
||||
> {
|
||||
constructor(
|
||||
private readonly flatMyEntityValidatorService: FlatMyEntityValidatorService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected buildCreateAction(
|
||||
universalFlatMyEntity: UniversalFlatMyEntity,
|
||||
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
||||
): BuildWorkspaceMigrationActionReturnType<UniversalCreateMyEntityAction> {
|
||||
const validationResult = this.flatMyEntityValidatorService.validateMyEntityForCreate(
|
||||
universalFlatMyEntity,
|
||||
flatEntityMaps.flatMyEntityMaps,
|
||||
);
|
||||
|
||||
if (validationResult.length > 0) {
|
||||
return {
|
||||
status: 'failed',
|
||||
errors: validationResult,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
action: {
|
||||
type: 'create',
|
||||
metadataName: 'myEntity',
|
||||
universalFlatEntity: universalFlatMyEntity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected buildUpdateAction(
|
||||
universalFlatMyEntity: UniversalFlatMyEntity,
|
||||
universalUpdates: Partial<UniversalFlatMyEntity>,
|
||||
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
||||
): BuildWorkspaceMigrationActionReturnType<UniversalUpdateMyEntityAction> {
|
||||
const validationResult = this.flatMyEntityValidatorService.validateMyEntityForUpdate(
|
||||
universalFlatMyEntity,
|
||||
universalUpdates,
|
||||
flatEntityMaps.flatMyEntityMaps,
|
||||
);
|
||||
|
||||
if (validationResult.length > 0) {
|
||||
return {
|
||||
status: 'failed',
|
||||
errors: validationResult,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
action: {
|
||||
type: 'update',
|
||||
metadataName: 'myEntity',
|
||||
universalFlatEntity: universalFlatMyEntity,
|
||||
universalUpdates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected buildDeleteAction(
|
||||
universalFlatMyEntity: UniversalFlatMyEntity,
|
||||
): BuildWorkspaceMigrationActionReturnType<UniversalDeleteMyEntityAction> {
|
||||
const validationResult = this.flatMyEntityValidatorService.validateMyEntityForDelete(
|
||||
universalFlatMyEntity,
|
||||
);
|
||||
|
||||
if (validationResult.length > 0) {
|
||||
return {
|
||||
status: 'failed',
|
||||
errors: validationResult,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
action: {
|
||||
type: 'delete',
|
||||
metadataName: 'myEntity',
|
||||
universalFlatEntity: universalFlatMyEntity,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Wire into Orchestrator (**CRITICAL**)
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-build-orchestrator.service.ts`
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class WorkspaceMigrationBuildOrchestratorService {
|
||||
constructor(
|
||||
// ... existing builders
|
||||
private readonly workspaceMigrationMyEntityActionsBuilderService: WorkspaceMigrationMyEntityActionsBuilderService,
|
||||
) {}
|
||||
|
||||
async buildWorkspaceMigration({
|
||||
allFlatEntityOperationByMetadataName,
|
||||
flatEntityMaps,
|
||||
isSystemBuild,
|
||||
}: BuildWorkspaceMigrationInput): Promise<BuildWorkspaceMigrationOutput> {
|
||||
// ... existing code
|
||||
|
||||
// Add your entity builder
|
||||
const myEntityResult = await this.workspaceMigrationMyEntityActionsBuilderService.build({
|
||||
flatEntitiesToCreate: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToCreate ?? [],
|
||||
flatEntitiesToUpdate: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToUpdate ?? [],
|
||||
flatEntitiesToDelete: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToDelete ?? [],
|
||||
flatEntityMaps,
|
||||
isSystemBuild,
|
||||
});
|
||||
|
||||
// ... aggregate errors
|
||||
|
||||
return {
|
||||
status: aggregatedErrors.length > 0 ? 'failed' : 'success',
|
||||
errors: aggregatedErrors,
|
||||
actions: [
|
||||
...existingActions,
|
||||
...myEntityResult.actions,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ This step is the most commonly forgotten!** Your entity won't sync without orchestrator wiring.
|
||||
|
||||
---
|
||||
|
||||
## Validation Patterns
|
||||
|
||||
### Pattern 1: Required Field
|
||||
```typescript
|
||||
if (!isDefined(field) || field.trim() === '') {
|
||||
errors.push({ code: ..., message: ..., userFriendlyMessage: ... });
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Uniqueness (O(1) lookup)
|
||||
```typescript
|
||||
const existing = optimisticMaps.byName[entity.name];
|
||||
if (isDefined(existing) && existing.id !== entity.id) {
|
||||
errors.push({ ... });
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Foreign Key Validation
|
||||
```typescript
|
||||
if (isDefined(entity.parentId)) {
|
||||
const parent = parentMaps.byId[entity.parentId];
|
||||
if (!isDefined(parent)) {
|
||||
errors.push({ code: NOT_FOUND, ... });
|
||||
} else if (isDefined(parent.deletedAt)) {
|
||||
errors.push({ code: DELETED, ... });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Standard Entity Protection
|
||||
```typescript
|
||||
if (entity.isCustom === false) {
|
||||
errors.push({ code: STANDARD_ENTITY_PROTECTED, ... });
|
||||
return errors; // Early return
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before moving to Step 4:
|
||||
|
||||
- [ ] Validator service created
|
||||
- [ ] Validator **never throws** (returns error arrays)
|
||||
- [ ] Validator **never mutates** (uses optimistic maps)
|
||||
- [ ] All uniqueness checks use indexed maps (O(1))
|
||||
- [ ] Required field validation implemented
|
||||
- [ ] Foreign key validation implemented
|
||||
- [ ] Standard entity protection implemented
|
||||
- [ ] Builder service extends `WorkspaceEntityMigrationBuilderService`
|
||||
- [ ] Builder creates actions with universal entities
|
||||
- [ ] **Builder wired into orchestrator** (**CRITICAL**)
|
||||
- [ ] **Builder injected in orchestrator constructor**
|
||||
- [ ] **Builder called in `buildWorkspaceMigration`**
|
||||
- [ ] **Actions added to orchestrator return statement**
|
||||
|
||||
---
|
||||
|
||||
## Next Step
|
||||
|
||||
Once builder and validation are complete, proceed to:
|
||||
**[Syncable Entity: Runner & Actions (Step 4/6)](../syncable-entity-runner-and-actions/SKILL.md)**
|
||||
|
||||
For complete workflow, see `@creating-syncable-entity` rule.
|
||||
@@ -1,303 +0,0 @@
|
||||
---
|
||||
name: syncable-entity-cache-and-transform
|
||||
description: Create cache services and transformation utilities for syncable entities in Twenty. Use when implementing entity-to-flat conversions, input DTO transpilation to universal flat entities, or cache recomputation for syncable entities.
|
||||
---
|
||||
|
||||
# Syncable Entity: Cache & Transform (Step 2/6)
|
||||
|
||||
**Purpose**: Create cache layer and transformation utilities to convert between different entity representations.
|
||||
|
||||
**When to use**: After completing Step 1 (Types & Constants). Required before building validators and action handlers.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
This step creates:
|
||||
1. Cache service for flat entity maps
|
||||
2. Entity-to-flat conversion utility
|
||||
3. Input transform utils (DTO → Universal Flat Entity)
|
||||
|
||||
**Key principle**: Input transform utils must output **universal flat entities** (with `universalIdentifier` and foreign keys mapped to universal identifiers).
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Cache Service
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-my-entity/services/flat-my-entity-cache.service.ts`
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { WorkspaceCache } from 'src/engine/twenty-orm/decorators/workspace-cache.decorator';
|
||||
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
||||
import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type';
|
||||
import { fromMyEntityEntityToFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util';
|
||||
|
||||
@Injectable()
|
||||
export class FlatMyEntityCacheService {
|
||||
constructor(
|
||||
@InjectRepository(MyEntityEntity, 'metadata')
|
||||
private readonly myEntityRepository: Repository<MyEntityEntity>,
|
||||
) {}
|
||||
|
||||
@WorkspaceCache({ flatMapsKey: 'flatMyEntityMaps' })
|
||||
async getFlatMyEntityMaps(): Promise<FlatMyEntityMaps> {
|
||||
const myEntities = await this.myEntityRepository.find({
|
||||
withDeleted: true, // CRITICAL: Include soft-deleted entities
|
||||
});
|
||||
|
||||
const flatMyEntities = myEntities.map((entity) =>
|
||||
fromMyEntityEntityToFlatMyEntity(entity),
|
||||
);
|
||||
|
||||
return {
|
||||
byId: Object.fromEntries(flatMyEntities.map((e) => [e.id, e])),
|
||||
byName: Object.fromEntries(flatMyEntities.map((e) => [e.name, e])),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical rules**:
|
||||
- Use `@WorkspaceCache` decorator with unique `flatMapsKey`
|
||||
- **Always** use `withDeleted: true` to include soft-deleted entities
|
||||
- Cache key pattern: `flat{EntityName}Maps` (camelCase)
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Entity-to-Flat Conversion
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util.ts`
|
||||
|
||||
```typescript
|
||||
import { v4 } from 'uuid';
|
||||
import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
||||
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
||||
|
||||
export const fromMyEntityEntityToFlatMyEntity = (
|
||||
entity: MyEntityEntity,
|
||||
): FlatMyEntity => {
|
||||
return {
|
||||
id: entity.id,
|
||||
// Critical: generate a new UUID for universalIdentifier
|
||||
universalIdentifier: v4(),
|
||||
workspaceId: entity.workspaceId,
|
||||
applicationId: entity.applicationId,
|
||||
name: entity.name,
|
||||
label: entity.label,
|
||||
description: entity.description,
|
||||
isCustom: entity.isCustom,
|
||||
parentEntityId: entity.parentEntityId,
|
||||
settings: entity.settings,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
deletedAt: entity.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Critical**: `universalIdentifier` must be a new UUID generated with `v4()` (not `entity.id`)
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Input Transform Utils (DTO → Universal Flat Entity)
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-my-entity/utils/from-create-my-entity-input-to-universal-flat-my-entity.util.ts`
|
||||
|
||||
```typescript
|
||||
import { v4 } from 'uuid';
|
||||
import { sanitizeString } from 'twenty-shared/string';
|
||||
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
|
||||
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
|
||||
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
|
||||
import { type AllFlatEntityMapsByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps-by-metadata-name.type';
|
||||
|
||||
export const fromCreateMyEntityInputToUniversalFlatMyEntity = ({
|
||||
input,
|
||||
workspaceId,
|
||||
flatEntityMaps,
|
||||
}: {
|
||||
input: CreateMyEntityInput;
|
||||
workspaceId: string;
|
||||
flatEntityMaps?: AllFlatEntityMapsByMetadataName;
|
||||
}): UniversalFlatMyEntity => {
|
||||
const id = v4();
|
||||
const universalIdentifier = v4();
|
||||
|
||||
// 1. Extract foreign key IDs BEFORE sanitization
|
||||
const parentEntityId = input.parentEntityId ?? null;
|
||||
|
||||
// 2. Sanitize string properties
|
||||
const name = sanitizeString(input.name);
|
||||
const label = sanitizeString(input.label);
|
||||
const description = input.description ? sanitizeString(input.description) : null;
|
||||
|
||||
// 3. Build base flat entity
|
||||
const baseFlatEntity = {
|
||||
id,
|
||||
universalIdentifier,
|
||||
workspaceId,
|
||||
applicationId: null,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
isCustom: true,
|
||||
parentEntityId,
|
||||
settings: input.settings ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
// 4. Resolve foreign keys to universal identifiers (if flatEntityMaps provided)
|
||||
if (flatEntityMaps) {
|
||||
return resolveEntityRelationUniversalIdentifiers({
|
||||
metadataName: 'myEntity',
|
||||
flatEntity: baseFlatEntity,
|
||||
flatEntityMaps,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Return with null universal foreign keys if no maps
|
||||
return {
|
||||
...baseFlatEntity,
|
||||
parentEntityUniversalIdentifier: null,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Key steps**:
|
||||
1. Generate IDs (`id` and `universalIdentifier` with `v4()`)
|
||||
2. Extract foreign keys **before** sanitization
|
||||
3. Sanitize all string properties
|
||||
4. Build base flat entity
|
||||
5. Resolve foreign keys → universal identifiers
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Flat Entity Module
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-my-entity/flat-my-entity.module.ts`
|
||||
|
||||
```typescript
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
||||
import { FlatMyEntityCacheService } from 'src/engine/metadata-modules/flat-my-entity/services/flat-my-entity-cache.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([MyEntityEntity], 'metadata')],
|
||||
providers: [FlatMyEntityCacheService],
|
||||
exports: [FlatMyEntityCacheService],
|
||||
})
|
||||
export class FlatMyEntityModule {}
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- Import entity with `'metadata'` datasource
|
||||
- Export cache service for use in other modules
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Foreign Key Resolution
|
||||
|
||||
```typescript
|
||||
// Extract foreign keys BEFORE sanitization
|
||||
const parentEntityId = input.parentEntityId ?? null;
|
||||
|
||||
// After building base entity, resolve to universal identifiers
|
||||
const universalFlatEntity = resolveEntityRelationUniversalIdentifiers({
|
||||
metadataName: 'myEntity',
|
||||
flatEntity: baseFlatEntity,
|
||||
flatEntityMaps,
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern: JSONB with SerializedRelation
|
||||
|
||||
```typescript
|
||||
// For JSONB properties containing foreign keys
|
||||
const settings = input.settings
|
||||
? {
|
||||
...input.settings,
|
||||
fieldMetadataId: input.settings.fieldMetadataId,
|
||||
}
|
||||
: null;
|
||||
|
||||
// After resolution, JSONB foreign keys become universal identifiers
|
||||
return resolveEntityRelationUniversalIdentifiers({
|
||||
metadataName: 'myEntity',
|
||||
flatEntity: { ...baseFlatEntity, settings },
|
||||
flatEntityMaps,
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern: Update Transform
|
||||
|
||||
```typescript
|
||||
// from-update-my-entity-input-to-universal-flat-my-entity-updates.util.ts
|
||||
export const fromUpdateMyEntityInputToUniversalFlatMyEntityUpdates = ({
|
||||
input,
|
||||
flatEntityMaps,
|
||||
}: {
|
||||
input: UpdateMyEntityInput;
|
||||
flatEntityMaps?: AllFlatEntityMapsByMetadataName;
|
||||
}): Partial<UniversalFlatMyEntity> => {
|
||||
const updates: Partial<UniversalFlatMyEntity> = {};
|
||||
|
||||
if (input.name !== undefined) {
|
||||
updates.name = sanitizeString(input.name);
|
||||
}
|
||||
|
||||
if (input.parentEntityId !== undefined) {
|
||||
updates.parentEntityId = input.parentEntityId;
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
|
||||
// Resolve foreign keys if maps provided
|
||||
if (flatEntityMaps) {
|
||||
return resolveEntityRelationUniversalIdentifiers({
|
||||
metadataName: 'myEntity',
|
||||
flatEntity: updates as any,
|
||||
flatEntityMaps,
|
||||
});
|
||||
}
|
||||
|
||||
return updates;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before moving to Step 3:
|
||||
|
||||
- [ ] Cache service created with `@WorkspaceCache` decorator
|
||||
- [ ] Cache uses `withDeleted: true`
|
||||
- [ ] Cache key follows `flat{EntityName}Maps` pattern
|
||||
- [ ] Entity-to-flat conversion implemented
|
||||
- [ ] `universalIdentifier` set correctly (generated with `v4()`)
|
||||
- [ ] Create input transform implemented
|
||||
- [ ] Update input transform implemented (if needed)
|
||||
- [ ] Foreign keys extracted before sanitization
|
||||
- [ ] String properties sanitized
|
||||
- [ ] Foreign keys resolved to universal identifiers
|
||||
- [ ] Flat entity module created and exports cache service
|
||||
|
||||
---
|
||||
|
||||
## Next Step
|
||||
|
||||
Once cache and transform utilities are complete, proceed to:
|
||||
**[Syncable Entity: Builder & Validation (Step 3/6)](../syncable-entity-builder-and-validation/SKILL.md)**
|
||||
|
||||
For complete workflow, see `@creating-syncable-entity` rule.
|
||||
@@ -1,326 +0,0 @@
|
||||
---
|
||||
name: syncable-entity-integration
|
||||
description: Wire syncable entity services into NestJS modules, create service layer and resolvers for Twenty entities. Use when registering builders, validators, and action handlers in modules, creating business services, or exposing entities via GraphQL API with proper exception handling.
|
||||
---
|
||||
|
||||
# Syncable Entity: Integration (Step 5/6)
|
||||
|
||||
**Purpose**: Wire everything together, register in modules, create services and resolvers.
|
||||
|
||||
**When to use**: After completing Steps 1-4 (all previous steps). Required before testing.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
This step:
|
||||
1. Registers services in 3 NestJS modules
|
||||
2. Creates service layer (returns flat entities)
|
||||
3. Creates resolver layer (converts flat → DTO)
|
||||
4. Uses exception interceptor for GraphQL
|
||||
|
||||
**Key principle**: Services return flat entities, resolvers transpile flat → DTO.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Register in Builder Module
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts`
|
||||
|
||||
```typescript
|
||||
import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// ... existing imports
|
||||
],
|
||||
providers: [
|
||||
// ... existing providers
|
||||
WorkspaceMigrationMyEntityActionsBuilderService,
|
||||
],
|
||||
exports: [
|
||||
// ... existing exports
|
||||
WorkspaceMigrationMyEntityActionsBuilderService,
|
||||
],
|
||||
})
|
||||
export class WorkspaceMigrationBuilderModule {}
|
||||
```
|
||||
|
||||
**Important**: Add to both `providers` AND `exports` (builder needs to be exported for orchestrator).
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Register in Validators Module
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts`
|
||||
|
||||
```typescript
|
||||
import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// ... existing imports
|
||||
],
|
||||
providers: [
|
||||
// ... existing providers
|
||||
FlatMyEntityValidatorService,
|
||||
],
|
||||
exports: [
|
||||
// ... existing exports
|
||||
FlatMyEntityValidatorService,
|
||||
],
|
||||
})
|
||||
export class WorkspaceMigrationBuilderValidatorsModule {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Register Action Handlers
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts`
|
||||
|
||||
```typescript
|
||||
import { CreateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service';
|
||||
import { UpdateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service';
|
||||
import { DeleteMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// ... existing imports
|
||||
],
|
||||
providers: [
|
||||
// ... existing providers
|
||||
CreateMyEntityActionHandlerService,
|
||||
UpdateMyEntityActionHandlerService,
|
||||
DeleteMyEntityActionHandlerService,
|
||||
],
|
||||
exports: [
|
||||
// ... existing exports (action handlers typically not exported)
|
||||
],
|
||||
})
|
||||
export class WorkspaceSchemaMigrationRunnerActionHandlersModule {}
|
||||
```
|
||||
|
||||
**Note**: Action handlers are typically only in `providers`, not `exports`.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Service Layer
|
||||
|
||||
**File**: `src/engine/metadata-modules/my-entity/my-entity.service.ts`
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { fromCreateMyEntityInputToUniversalFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-create-my-entity-input-to-universal-flat-my-entity.util';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
@Injectable()
|
||||
export class MyEntityService {
|
||||
constructor(
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
) {}
|
||||
|
||||
async create(input: CreateMyEntityInput, workspaceId: string): Promise<FlatMyEntity> {
|
||||
// 1. Transform input to universal flat entity
|
||||
const universalFlatMyEntityToCreate = fromCreateMyEntityInputToUniversalFlatMyEntity({
|
||||
input,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
// 2. Validate, build, and run
|
||||
const result =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
myEntity: {
|
||||
flatEntityToCreate: [universalFlatMyEntityToCreate],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
isSystemBuild: false,
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Throw if validation failed
|
||||
if (isDefined(result)) {
|
||||
throw new WorkspaceMigrationBuilderException(
|
||||
result,
|
||||
'Validation errors occurred while creating entity',
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Return freshly cached flat entity
|
||||
const { flatMyEntityMaps } =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatMyEntityMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
return findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityId: universalFlatMyEntityToCreate.id,
|
||||
flatEntityMaps: flatMyEntityMaps,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Service pattern**:
|
||||
1. Transform input → universal flat entity
|
||||
2. Call `validateBuildAndRunWorkspaceMigration`
|
||||
3. Throw if validation errors
|
||||
4. **Return flat entity** (not DTO)
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Create Resolver Layer
|
||||
|
||||
**File**: `src/engine/metadata-modules/my-entity/my-entity.resolver.ts`
|
||||
|
||||
```typescript
|
||||
import { UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
import { MyEntityService } from 'src/engine/metadata-modules/my-entity/my-entity.service';
|
||||
import { fromFlatMyEntityToMyEntityDto } from 'src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util';
|
||||
|
||||
@Resolver(() => MyEntityDto)
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
export class MyEntityResolver {
|
||||
constructor(private readonly myEntityService: MyEntityService) {}
|
||||
|
||||
@Mutation(() => MyEntityDto)
|
||||
async createMyEntity(
|
||||
@Args('input') input: CreateMyEntityInput,
|
||||
@Workspace() { id: workspaceId }: Workspace,
|
||||
): Promise<MyEntityDto> {
|
||||
// Service returns flat entity
|
||||
const flatMyEntity = await this.myEntityService.create(input, workspaceId);
|
||||
|
||||
// Resolver converts flat entity to DTO
|
||||
return fromFlatMyEntityToMyEntityDto(flatMyEntity);
|
||||
}
|
||||
|
||||
@Mutation(() => MyEntityDto)
|
||||
async updateMyEntity(
|
||||
@Args('id') id: string,
|
||||
@Args('input') input: UpdateMyEntityInput,
|
||||
@Workspace() { id: workspaceId }: Workspace,
|
||||
): Promise<MyEntityDto> {
|
||||
const flatMyEntity = await this.myEntityService.update(id, input, workspaceId);
|
||||
return fromFlatMyEntityToMyEntityDto(flatMyEntity);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteMyEntity(
|
||||
@Args('id') id: string,
|
||||
@Workspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
await this.myEntityService.delete(id, workspaceId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Resolver responsibilities**:
|
||||
- Receives flat entities from service
|
||||
- **Converts flat → DTO** using conversion utility
|
||||
- Returns DTOs to GraphQL API
|
||||
- Uses exception interceptor for error formatting
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Flat-to-DTO Conversion
|
||||
|
||||
**File**: `src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util.ts`
|
||||
|
||||
```typescript
|
||||
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
||||
import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto';
|
||||
|
||||
export const fromFlatMyEntityToMyEntityDto = (
|
||||
flatMyEntity: FlatMyEntity,
|
||||
): MyEntityDto => {
|
||||
return {
|
||||
id: flatMyEntity.id,
|
||||
name: flatMyEntity.name,
|
||||
label: flatMyEntity.label,
|
||||
description: flatMyEntity.description,
|
||||
isCustom: flatMyEntity.isCustom,
|
||||
createdAt: flatMyEntity.createdAt,
|
||||
updatedAt: flatMyEntity.updatedAt,
|
||||
// Convert foreign key IDs to relation objects if needed
|
||||
// parentEntity: flatMyEntity.parentEntityId ? { id: flatMyEntity.parentEntityId } : null,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layer Responsibilities
|
||||
|
||||
| Layer | Input | Output | Responsibility |
|
||||
|-------|-------|--------|----------------|
|
||||
| **Service** | Input DTO | Flat Entity | Business logic, validation orchestration |
|
||||
| **Resolver** | Service result | DTO | Flat → DTO conversion, GraphQL exposure |
|
||||
|
||||
**Service Layer**:
|
||||
- Works with flat entities internally
|
||||
- Returns `FlatMyEntity` type
|
||||
- No knowledge of DTOs or GraphQL types
|
||||
|
||||
**Resolver Layer**:
|
||||
- Receives flat entities from service
|
||||
- Converts flat entities to DTOs
|
||||
- Returns DTOs to GraphQL API
|
||||
|
||||
---
|
||||
|
||||
## Exception Interceptor
|
||||
|
||||
The `WorkspaceMigrationGraphqlApiExceptionInterceptor` automatically handles:
|
||||
|
||||
1. `FlatEntityMapsException` → Converts to GraphQL errors (NotFoundError, etc.)
|
||||
2. `WorkspaceMigrationBuilderException` → Formats validation errors with i18n
|
||||
3. `WorkspaceMigrationRunnerException` → Formats runner errors
|
||||
|
||||
**What it does**:
|
||||
- Catches exceptions and formats for API responses
|
||||
- Translates error messages based on user locale
|
||||
- Ensures consistent error structure for frontend
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before moving to Step 6 (Testing):
|
||||
|
||||
- [ ] Builder registered in builder module (providers + exports)
|
||||
- [ ] Validator registered in validators module (providers + exports)
|
||||
- [ ] All 3 action handlers registered in action handlers module (providers)
|
||||
- [ ] Service layer created
|
||||
- [ ] Service returns flat entities (not DTOs)
|
||||
- [ ] Resolver layer created
|
||||
- [ ] Resolver uses exception interceptor
|
||||
- [ ] Resolver converts flat → DTO
|
||||
- [ ] Flat-to-DTO conversion utility created
|
||||
|
||||
---
|
||||
|
||||
## Next Step
|
||||
|
||||
Once integration is complete, proceed to (**MANDATORY**):
|
||||
**[Syncable Entity: Integration Testing (Step 6/6)](../syncable-entity-testing/SKILL.md)**
|
||||
|
||||
For complete workflow, see `@creating-syncable-entity` rule.
|
||||
@@ -1,355 +0,0 @@
|
||||
---
|
||||
name: syncable-entity-runner-and-actions
|
||||
description: Implement action handlers for executing workspace migrations in Twenty. Use when creating database operations for syncable entities, implementing universal-to-flat entity transpilation, or handling create/update/delete actions in the runner layer.
|
||||
---
|
||||
|
||||
# Syncable Entity: Runner & Actions (Step 4/6)
|
||||
|
||||
**Purpose**: Execute migration actions against the database with proper transpilation from universal to flat entities.
|
||||
|
||||
**When to use**: After completing Steps 1-3 (Types, Cache, Builder). Required before integration.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
This step creates:
|
||||
1. Create action handler
|
||||
2. Update action handler
|
||||
3. Delete action handler
|
||||
4. Universal-to-flat conversion utilities
|
||||
|
||||
**Key pattern**: Each handler has two phases:
|
||||
1. **Transpilation**: Universal action → Flat action
|
||||
2. **Execution**: Flat action → Database operation
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Action Handler
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service.ts`
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WorkspaceCreateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-create-action-handler.service';
|
||||
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
||||
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
|
||||
import {
|
||||
type UniversalCreateMyEntityAction,
|
||||
type FlatCreateMyEntityAction,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
|
||||
|
||||
@Injectable()
|
||||
export class CreateMyEntityActionHandlerService extends WorkspaceCreateActionHandlerService<
|
||||
'myEntity',
|
||||
UniversalCreateMyEntityAction,
|
||||
FlatCreateMyEntityAction
|
||||
> {
|
||||
constructor(
|
||||
@InjectRepository(MyEntityEntity, 'metadata')
|
||||
private readonly myEntityRepository: Repository<MyEntityEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
// Phase 1: Transpile universal action to flat action
|
||||
protected transpileUniversalActionToFlatAction(
|
||||
universalAction: UniversalCreateMyEntityAction,
|
||||
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
||||
): FlatCreateMyEntityAction {
|
||||
return {
|
||||
type: 'create',
|
||||
metadataName: 'myEntity',
|
||||
flatEntity: fromUniversalFlatMyEntityToFlatMyEntity(
|
||||
universalAction.universalFlatEntity,
|
||||
flatEntityMaps,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 2: Execute flat action against database
|
||||
protected async executeForMetadata(
|
||||
flatActions: FlatCreateMyEntityAction[],
|
||||
): Promise<void> {
|
||||
const flatEntities = flatActions.map((action) => action.flatEntity);
|
||||
|
||||
await this.insertFlatEntitiesInRepository({
|
||||
repository: this.myEntityRepository,
|
||||
flatEntities,
|
||||
});
|
||||
}
|
||||
|
||||
protected async executeForWorkspaceSchema(): Promise<void> {
|
||||
// No workspace schema changes needed for metadata-only entity
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key helper methods**:
|
||||
- `transpileUniversalActionToFlatAction`: Converts universal → flat
|
||||
- `insertFlatEntitiesInRepository`: Base class helper for inserts
|
||||
- `executeForMetadata`: Metadata database operations
|
||||
- `executeForWorkspaceSchema`: Workspace schema changes (if needed)
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Update Action Handler
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service.ts`
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WorkspaceUpdateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-update-action-handler.service';
|
||||
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
||||
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
|
||||
import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util';
|
||||
|
||||
@Injectable()
|
||||
export class UpdateMyEntityActionHandlerService extends WorkspaceUpdateActionHandlerService<
|
||||
'myEntity',
|
||||
UniversalUpdateMyEntityAction,
|
||||
FlatUpdateMyEntityAction
|
||||
> {
|
||||
constructor(
|
||||
@InjectRepository(MyEntityEntity, 'metadata')
|
||||
private readonly myEntityRepository: Repository<MyEntityEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected transpileUniversalActionToFlatAction(
|
||||
universalAction: UniversalUpdateMyEntityAction,
|
||||
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
||||
): FlatUpdateMyEntityAction {
|
||||
const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity(
|
||||
universalAction.universalFlatEntity,
|
||||
flatEntityMaps,
|
||||
);
|
||||
|
||||
// Resolve universal foreign keys in updates to regular IDs
|
||||
const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({
|
||||
metadataName: 'myEntity',
|
||||
universalUpdates: universalAction.universalUpdates,
|
||||
flatEntityMaps,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'update',
|
||||
metadataName: 'myEntity',
|
||||
flatEntity,
|
||||
updates: flatUpdates,
|
||||
};
|
||||
}
|
||||
|
||||
protected async executeForMetadata(
|
||||
flatActions: FlatUpdateMyEntityAction[],
|
||||
): Promise<void> {
|
||||
for (const action of flatActions) {
|
||||
await this.myEntityRepository.update(
|
||||
{ id: action.flatEntity.id },
|
||||
action.updates,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected async executeForWorkspaceSchema(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Update-specific helper**:
|
||||
- `resolveUniversalUpdateRelationIdentifiersToIds`: Maps universal identifiers back to regular IDs in the updates object
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Delete Action Handler
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service.ts`
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WorkspaceDeleteActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-delete-action-handler.service';
|
||||
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
||||
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteMyEntityActionHandlerService extends WorkspaceDeleteActionHandlerService<
|
||||
'myEntity',
|
||||
UniversalDeleteMyEntityAction,
|
||||
FlatDeleteMyEntityAction
|
||||
> {
|
||||
constructor(
|
||||
@InjectRepository(MyEntityEntity, 'metadata')
|
||||
private readonly myEntityRepository: Repository<MyEntityEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected transpileUniversalActionToFlatAction(
|
||||
universalAction: UniversalDeleteMyEntityAction,
|
||||
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
||||
): FlatDeleteMyEntityAction {
|
||||
// Use base class helper for delete transpilation
|
||||
return this.transpileUniversalDeleteActionToFlatDeleteAction({
|
||||
universalAction,
|
||||
flatEntityMaps,
|
||||
fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity,
|
||||
});
|
||||
}
|
||||
|
||||
protected async executeForMetadata(
|
||||
flatActions: FlatDeleteMyEntityAction[],
|
||||
): Promise<void> {
|
||||
const ids = flatActions.map((action) => action.flatEntity.id);
|
||||
|
||||
await this.myEntityRepository.delete(ids);
|
||||
}
|
||||
|
||||
protected async executeForWorkspaceSchema(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Delete-specific helper**:
|
||||
- `transpileUniversalDeleteActionToFlatDeleteAction`: Base class helper that handles standard delete transpilation
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Universal-to-Flat Conversion
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util.ts`
|
||||
|
||||
```typescript
|
||||
import { resolveUniversalRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util';
|
||||
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
|
||||
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
||||
import { type AllFlatEntityMapsByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps-by-metadata-name.type';
|
||||
|
||||
export const fromUniversalFlatMyEntityToFlatMyEntity = (
|
||||
universalFlatMyEntity: UniversalFlatMyEntity,
|
||||
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
||||
): FlatMyEntity => {
|
||||
// Resolve universal foreign keys back to regular IDs
|
||||
return resolveUniversalRelationIdentifiersToIds({
|
||||
metadataName: 'myEntity',
|
||||
universalFlatEntity: universalFlatMyEntity,
|
||||
flatEntityMaps,
|
||||
}) as FlatMyEntity;
|
||||
};
|
||||
```
|
||||
|
||||
**Key utility**:
|
||||
- `resolveUniversalRelationIdentifiersToIds`: Maps universal identifiers → regular IDs (reverse of `resolveEntityRelationUniversalIdentifiers`)
|
||||
|
||||
---
|
||||
|
||||
## Action Handler Patterns
|
||||
|
||||
### Pattern: Create Handler
|
||||
```typescript
|
||||
// 1. Transpile: Universal → Flat
|
||||
protected transpileUniversalActionToFlatAction(
|
||||
universalAction,
|
||||
flatEntityMaps,
|
||||
) {
|
||||
return {
|
||||
type: 'create',
|
||||
metadataName: 'myEntity',
|
||||
flatEntity: fromUniversalFlatMyEntityToFlatMyEntity(
|
||||
universalAction.universalFlatEntity,
|
||||
flatEntityMaps,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Execute: Flat → Database
|
||||
protected async executeForMetadata(flatActions) {
|
||||
await this.insertFlatEntitiesInRepository({
|
||||
repository: this.myEntityRepository,
|
||||
flatEntities: flatActions.map(a => a.flatEntity),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Update Handler
|
||||
```typescript
|
||||
// Transpile with update-specific resolution
|
||||
protected transpileUniversalActionToFlatAction(
|
||||
universalAction,
|
||||
flatEntityMaps,
|
||||
) {
|
||||
const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity(
|
||||
universalAction.universalFlatEntity,
|
||||
flatEntityMaps,
|
||||
);
|
||||
|
||||
const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({
|
||||
metadataName: 'myEntity',
|
||||
universalUpdates: universalAction.universalUpdates,
|
||||
flatEntityMaps,
|
||||
});
|
||||
|
||||
return { type: 'update', metadataName: 'myEntity', flatEntity, updates: flatUpdates };
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Delete Handler
|
||||
```typescript
|
||||
// Use base class helper
|
||||
protected transpileUniversalActionToFlatAction(
|
||||
universalAction,
|
||||
flatEntityMaps,
|
||||
) {
|
||||
return this.transpileUniversalDeleteActionToFlatDeleteAction({
|
||||
universalAction,
|
||||
flatEntityMaps,
|
||||
fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete
|
||||
protected async executeForMetadata(flatActions) {
|
||||
const ids = flatActions.map(a => a.flatEntity.id);
|
||||
await this.myEntityRepository.delete(ids);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before moving to Step 5:
|
||||
|
||||
- [ ] Create action handler implemented
|
||||
- [ ] Update action handler implemented
|
||||
- [ ] Delete action handler implemented
|
||||
- [ ] All handlers extend appropriate base class
|
||||
- [ ] `transpileUniversalActionToFlatAction` implemented in all handlers
|
||||
- [ ] `executeForMetadata` implemented in all handlers
|
||||
- [ ] `executeForWorkspaceSchema` implemented (or returns empty)
|
||||
- [ ] Universal-to-flat conversion utility created
|
||||
- [ ] Create handler uses `insertFlatEntitiesInRepository`
|
||||
- [ ] Update handler uses `resolveUniversalUpdateRelationIdentifiersToIds`
|
||||
- [ ] Delete handler uses `transpileUniversalDeleteActionToFlatDeleteAction`
|
||||
- [ ] Delete handler uses hard delete (`delete()`)
|
||||
|
||||
---
|
||||
|
||||
## Next Step
|
||||
|
||||
Once action handlers are complete, proceed to:
|
||||
**[Syncable Entity: Integration (Step 5/6)](../syncable-entity-integration/SKILL.md)**
|
||||
|
||||
For complete workflow, see `@creating-syncable-entity` rule.
|
||||
@@ -1,494 +0,0 @@
|
||||
---
|
||||
name: syncable-entity-testing
|
||||
description: Create comprehensive integration tests for syncable entities in Twenty. Use when writing integration tests for metadata entities, covering validator exceptions, input transpilation errors, and CRUD operations. Tests are MANDATORY for all syncable entities.
|
||||
---
|
||||
|
||||
# Syncable Entity: Integration Testing (Step 6/6 - MANDATORY)
|
||||
|
||||
**Purpose**: Create comprehensive test suite covering all validation scenarios, input transpilation exceptions, and successful use cases.
|
||||
|
||||
**When to use**: After completing Steps 1-5. Integration tests are **REQUIRED** for all syncable entities.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Tests must cover:
|
||||
1. **Failing scenarios** - All validator exceptions and input transpilation errors
|
||||
2. **Successful scenarios** - All CRUD operations and edge cases
|
||||
3. **Test utilities** - Reusable query factories and helper functions
|
||||
|
||||
**Test pattern**: Two-file pattern (query factory + wrapper) for each operation.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Test Utilities
|
||||
|
||||
### Pattern: Query Factory
|
||||
|
||||
**File**: `test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util.ts`
|
||||
|
||||
```typescript
|
||||
import gql from 'graphql-tag';
|
||||
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
|
||||
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
|
||||
|
||||
export type CreateMyEntityFactoryInput = CreateMyEntityInput;
|
||||
|
||||
const DEFAULT_MY_ENTITY_GQL_FIELDS = `
|
||||
id
|
||||
name
|
||||
label
|
||||
description
|
||||
isCustom
|
||||
createdAt
|
||||
updatedAt
|
||||
`;
|
||||
|
||||
export const createMyEntityQueryFactory = ({
|
||||
input,
|
||||
gqlFields = DEFAULT_MY_ENTITY_GQL_FIELDS,
|
||||
}: PerformMetadataQueryParams<CreateMyEntityFactoryInput>) => ({
|
||||
query: gql`
|
||||
mutation CreateMyEntity($input: CreateMyEntityInput!) {
|
||||
createMyEntity(input: $input) {
|
||||
${gqlFields}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern: Wrapper Utility
|
||||
|
||||
**File**: `test/integration/metadata/suites/my-entity/utils/create-my-entity.util.ts`
|
||||
|
||||
```typescript
|
||||
import {
|
||||
type CreateMyEntityFactoryInput,
|
||||
createMyEntityQueryFactory,
|
||||
} from 'test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto';
|
||||
|
||||
export const createMyEntity = async ({
|
||||
input,
|
||||
gqlFields,
|
||||
expectToFail = false,
|
||||
token,
|
||||
}: PerformMetadataQueryParams<CreateMyEntityFactoryInput>): CommonResponseBody<{
|
||||
createMyEntity: MyEntityDto;
|
||||
}> => {
|
||||
const graphqlOperation = createMyEntityQueryFactory({
|
||||
input,
|
||||
gqlFields,
|
||||
});
|
||||
|
||||
const response = await makeMetadataAPIRequest(graphqlOperation, token);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'My entity creation should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'My entity creation has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
```
|
||||
|
||||
**Required utilities** (follow same pattern):
|
||||
- `update-my-entity-query-factory.util.ts` + `update-my-entity.util.ts`
|
||||
- `delete-my-entity-query-factory.util.ts` + `delete-my-entity.util.ts`
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Failing Creation Tests
|
||||
|
||||
**File**: `test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts`
|
||||
|
||||
```typescript
|
||||
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
|
||||
import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util';
|
||||
import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util';
|
||||
import {
|
||||
eachTestingContextFilter,
|
||||
type EachTestingContext,
|
||||
} from 'twenty-shared/testing';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
|
||||
|
||||
type TestContext = {
|
||||
input: CreateMyEntityInput;
|
||||
};
|
||||
|
||||
type GlobalTestContext = {
|
||||
existingEntityLabel: string;
|
||||
existingEntityName: string;
|
||||
};
|
||||
|
||||
const globalTestContext: GlobalTestContext = {
|
||||
existingEntityLabel: 'Existing Test Entity',
|
||||
existingEntityName: 'existingTestEntity',
|
||||
};
|
||||
|
||||
type CreateMyEntityTestingContext = EachTestingContext<TestContext>[];
|
||||
|
||||
describe('My entity creation should fail', () => {
|
||||
let existingEntityId: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Setup: Create entity for uniqueness tests
|
||||
const { data } = await createMyEntity({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
name: globalTestContext.existingEntityName,
|
||||
label: globalTestContext.existingEntityLabel,
|
||||
},
|
||||
});
|
||||
|
||||
existingEntityId = data.createMyEntity.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
if (isDefined(existingEntityId)) {
|
||||
await deleteMyEntity({
|
||||
expectToFail: false,
|
||||
input: { id: existingEntityId },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const failingMyEntityCreationTestCases: CreateMyEntityTestingContext = [
|
||||
// Input transpilation validation
|
||||
{
|
||||
title: 'when name is missing',
|
||||
context: {
|
||||
input: {
|
||||
label: 'Entity Missing Name',
|
||||
} as CreateMyEntityInput,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when label is missing',
|
||||
context: {
|
||||
input: {
|
||||
name: 'entityMissingLabel',
|
||||
} as CreateMyEntityInput,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when name is empty string',
|
||||
context: {
|
||||
input: {
|
||||
name: '',
|
||||
label: 'Empty Name Entity',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Validator business logic
|
||||
{
|
||||
title: 'when name already exists (uniqueness)',
|
||||
context: {
|
||||
input: {
|
||||
name: globalTestContext.existingEntityName,
|
||||
label: 'Duplicate Name Entity',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when trying to create standard entity',
|
||||
context: {
|
||||
input: {
|
||||
name: 'myEntity',
|
||||
label: 'Standard Entity',
|
||||
isCustom: false,
|
||||
} as CreateMyEntityInput,
|
||||
},
|
||||
},
|
||||
|
||||
// Foreign key validation
|
||||
{
|
||||
title: 'when parentEntityId does not exist',
|
||||
context: {
|
||||
input: {
|
||||
name: 'invalidParentEntity',
|
||||
label: 'Invalid Parent Entity',
|
||||
parentEntityId: '00000000-0000-0000-0000-000000000000',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(eachTestingContextFilter(failingMyEntityCreationTestCases))(
|
||||
'$title',
|
||||
async ({ context }) => {
|
||||
const { errors } = await createMyEntity({
|
||||
expectToFail: true,
|
||||
input: context.input,
|
||||
});
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({
|
||||
errors,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Test coverage requirements**:
|
||||
- ✅ Missing required fields
|
||||
- ✅ Empty strings
|
||||
- ✅ Invalid format
|
||||
- ✅ Uniqueness violations
|
||||
- ✅ Standard entity protection
|
||||
- ✅ Foreign key validation
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Successful Creation Tests
|
||||
|
||||
**File**: `test/integration/metadata/suites/my-entity/successful-my-entity-creation.integration-spec.ts`
|
||||
|
||||
```typescript
|
||||
import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util';
|
||||
import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util';
|
||||
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
|
||||
|
||||
describe('My entity creation should succeed', () => {
|
||||
let createdEntityId: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdEntityId) {
|
||||
await deleteMyEntity({
|
||||
expectToFail: false,
|
||||
input: { id: createdEntityId },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should create entity with minimal required input', async () => {
|
||||
const { data } = await createMyEntity({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
name: 'minimalEntity',
|
||||
label: 'Minimal Entity',
|
||||
},
|
||||
});
|
||||
|
||||
createdEntityId = data?.createMyEntity?.id;
|
||||
|
||||
expect(data.createMyEntity).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: 'minimalEntity',
|
||||
label: 'Minimal Entity',
|
||||
description: null,
|
||||
isCustom: true,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create entity with all optional fields', async () => {
|
||||
const input = {
|
||||
name: 'fullEntity',
|
||||
label: 'Full Entity',
|
||||
description: 'Entity with all fields specified',
|
||||
} as const satisfies CreateMyEntityInput;
|
||||
|
||||
const { data } = await createMyEntity({
|
||||
expectToFail: false,
|
||||
input,
|
||||
});
|
||||
|
||||
createdEntityId = data?.createMyEntity?.id;
|
||||
|
||||
expect(data.createMyEntity).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: 'fullEntity',
|
||||
label: 'Full Entity',
|
||||
description: 'Entity with all fields specified',
|
||||
isCustom: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize input by trimming whitespace', async () => {
|
||||
const { data } = await createMyEntity({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
name: ' entityWithSpaces ',
|
||||
label: ' Entity With Spaces ',
|
||||
description: ' Description with spaces ',
|
||||
},
|
||||
});
|
||||
|
||||
createdEntityId = data?.createMyEntity?.id;
|
||||
|
||||
expect(data.createMyEntity).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: 'entityWithSpaces',
|
||||
label: 'Entity With Spaces',
|
||||
description: 'Description with spaces',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle long text content', async () => {
|
||||
const longDescription = 'A'.repeat(1000);
|
||||
|
||||
const { data } = await createMyEntity({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
name: 'longDescEntity',
|
||||
label: 'Long Description Entity',
|
||||
description: longDescription,
|
||||
},
|
||||
});
|
||||
|
||||
createdEntityId = data?.createMyEntity?.id;
|
||||
|
||||
expect(data.createMyEntity).toMatchObject({
|
||||
id: expect.any(String),
|
||||
description: longDescription,
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Test coverage requirements**:
|
||||
- ✅ Minimal required input
|
||||
- ✅ All optional fields
|
||||
- ✅ Input sanitization
|
||||
- ✅ Long text content
|
||||
- ✅ Special characters
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Update and Delete Tests
|
||||
|
||||
Create similar test files for update and delete operations:
|
||||
|
||||
**Required files**:
|
||||
- `failing-my-entity-update.integration-spec.ts`
|
||||
- `successful-my-entity-update.integration-spec.ts`
|
||||
- `failing-my-entity-deletion.integration-spec.ts`
|
||||
- `successful-my-entity-deletion.integration-spec.ts`
|
||||
|
||||
---
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### Pattern: Cleanup
|
||||
```typescript
|
||||
afterEach(async () => {
|
||||
if (createdEntityId) {
|
||||
await deleteMyEntity({
|
||||
expectToFail: false,
|
||||
input: { id: createdEntityId },
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern: Type-Safe Inputs
|
||||
```typescript
|
||||
const input = {
|
||||
name: 'myEntity',
|
||||
label: 'My Entity',
|
||||
} as const satisfies CreateMyEntityInput;
|
||||
```
|
||||
|
||||
### Pattern: Snapshot Testing
|
||||
```typescript
|
||||
expectOneNotInternalServerErrorSnapshot({
|
||||
errors,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all entity tests
|
||||
npx jest test/integration/metadata/suites/my-entity --config=packages/twenty-server/jest.config.mjs
|
||||
|
||||
# Run specific test file
|
||||
npx jest test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts --config=packages/twenty-server/jest.config.mjs
|
||||
|
||||
# Update snapshots
|
||||
npx jest test/integration/metadata/suites/my-entity --updateSnapshot --config=packages/twenty-server/jest.config.mjs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Test Checklist
|
||||
|
||||
### Test Utilities
|
||||
- [ ] `create-my-entity-query-factory.util.ts` created
|
||||
- [ ] `create-my-entity.util.ts` created
|
||||
- [ ] `update-my-entity-query-factory.util.ts` created
|
||||
- [ ] `update-my-entity.util.ts` created
|
||||
- [ ] `delete-my-entity-query-factory.util.ts` created
|
||||
- [ ] `delete-my-entity.util.ts` created
|
||||
|
||||
### Failing Tests Coverage
|
||||
- [ ] Missing required fields
|
||||
- [ ] Empty string validation
|
||||
- [ ] Uniqueness violations
|
||||
- [ ] Standard entity protection
|
||||
- [ ] Foreign key validation
|
||||
- [ ] JSONB property validation (if applicable)
|
||||
|
||||
### Successful Tests Coverage
|
||||
- [ ] Create with minimal input
|
||||
- [ ] Create with all optional fields
|
||||
- [ ] Input sanitization (whitespace)
|
||||
- [ ] Long text content
|
||||
- [ ] Update single field
|
||||
- [ ] Update multiple fields
|
||||
- [ ] Successful deletion
|
||||
|
||||
### Snapshot Tests
|
||||
- [ ] All failing tests use `expectOneNotInternalServerErrorSnapshot`
|
||||
- [ ] Snapshots committed to `__snapshots__/` directory
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Your integration tests are complete when:
|
||||
|
||||
✅ All test utilities created (minimum 6 files)
|
||||
✅ Failing creation tests cover all validators
|
||||
✅ Failing update tests cover business rules
|
||||
✅ Failing deletion tests cover protection rules
|
||||
✅ Successful tests cover all use cases
|
||||
✅ All snapshots generated and committed
|
||||
✅ All tests pass consistently
|
||||
✅ Test coverage meets requirements (>80%)
|
||||
|
||||
---
|
||||
|
||||
## Final Step
|
||||
|
||||
✅ **Step 6 Complete!** → Your syncable entity is fully tested and production-ready!
|
||||
|
||||
**Congratulations!** You've successfully created a new syncable entity in Twenty's workspace migration system.
|
||||
|
||||
For complete workflow, see `@creating-syncable-entity` rule.
|
||||
@@ -1,340 +0,0 @@
|
||||
---
|
||||
name: syncable-entity-types-and-constants
|
||||
description: Define types, entities, and central constant registrations for syncable entities in Twenty's workspace migration system. Use when creating new syncable entities, defining TypeORM entities, flat entity types, or registering in central constants (ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME, ALL_ONE_TO_MANY_METADATA_RELATIONS, ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY, ALL_MANY_TO_ONE_METADATA_RELATIONS).
|
||||
---
|
||||
|
||||
# Syncable Entity: Types & Constants (Step 1/6)
|
||||
|
||||
**Purpose**: Define all types, entities, and register in central constants. This is the foundation - everything else depends on these types being correct.
|
||||
|
||||
**When to use**: First step when creating any new syncable entity. Must be completed before other steps.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
This step creates:
|
||||
1. Metadata name constant (twenty-shared)
|
||||
2. TypeORM entity (extends `SyncableEntity`)
|
||||
3. Flat entity types
|
||||
4. Action types (universal + flat)
|
||||
5. Central constant registrations (5 constants)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Add Metadata Name
|
||||
|
||||
**File**: `packages/twenty-shared/src/metadata/all-metadata-name.constant.ts`
|
||||
|
||||
```typescript
|
||||
export const ALL_METADATA_NAME = {
|
||||
// ... existing entries
|
||||
myEntity: 'myEntity',
|
||||
} as const;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create TypeORM Entity
|
||||
|
||||
**File**: `src/engine/metadata-modules/my-entity/entities/my-entity.entity.ts`
|
||||
|
||||
```typescript
|
||||
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
|
||||
|
||||
@Entity({ name: 'myEntity' })
|
||||
export class MyEntityEntity extends SyncableEntity {
|
||||
@Column({ type: 'varchar' })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
label: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isCustom: boolean;
|
||||
|
||||
// Foreign key example (optional)
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
parentEntityId: string | null;
|
||||
|
||||
@ManyToOne(() => ParentEntityEntity, { nullable: true })
|
||||
@JoinColumn({ name: 'parentEntityId' })
|
||||
parentEntity: ParentEntityEntity | null;
|
||||
|
||||
// JSONB column example (optional)
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
settings: Record<string, any> | null;
|
||||
}
|
||||
```
|
||||
|
||||
**Key rules**:
|
||||
- Must extend `SyncableEntity` (provides `id`, `universalIdentifier`, `applicationId`, etc.)
|
||||
- Must have `isCustom` boolean column
|
||||
- Use `@Column({ type: 'jsonb' })` for JSON data
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Define Flat Entity Types
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type.ts`
|
||||
|
||||
```typescript
|
||||
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
|
||||
import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
||||
|
||||
export type FlatMyEntity = FlatEntityFrom<MyEntityEntity>;
|
||||
```
|
||||
|
||||
**Maps file** (if entity has indexed lookups):
|
||||
|
||||
```typescript
|
||||
// flat-my-entity-maps.type.ts
|
||||
export type FlatMyEntityMaps = {
|
||||
byId: Record<string, FlatMyEntity>;
|
||||
byName: Record<string, FlatMyEntity>;
|
||||
// Add other indexes as needed
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Define Editable Properties
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-my-entity/constants/editable-flat-my-entity-properties.constant.ts`
|
||||
|
||||
```typescript
|
||||
export const EDITABLE_FLAT_MY_ENTITY_PROPERTIES = [
|
||||
'name',
|
||||
'label',
|
||||
'description',
|
||||
'parentEntityId',
|
||||
'settings',
|
||||
] as const satisfies ReadonlyArray<keyof FlatMyEntity>;
|
||||
```
|
||||
|
||||
**Rule**: Only include properties that can be updated (exclude `id`, `createdAt`, `universalIdentifier`, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Define Action Types
|
||||
|
||||
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type.ts`
|
||||
|
||||
```typescript
|
||||
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
||||
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
|
||||
|
||||
// Universal actions (used by builder/runner)
|
||||
export type UniversalCreateMyEntityAction = {
|
||||
type: 'create';
|
||||
metadataName: 'myEntity';
|
||||
universalFlatEntity: UniversalFlatMyEntity;
|
||||
};
|
||||
|
||||
export type UniversalUpdateMyEntityAction = {
|
||||
type: 'update';
|
||||
metadataName: 'myEntity';
|
||||
universalFlatEntity: UniversalFlatMyEntity;
|
||||
universalUpdates: Partial<UniversalFlatMyEntity>;
|
||||
};
|
||||
|
||||
export type UniversalDeleteMyEntityAction = {
|
||||
type: 'delete';
|
||||
metadataName: 'myEntity';
|
||||
universalFlatEntity: UniversalFlatMyEntity;
|
||||
};
|
||||
|
||||
// Flat actions (internal to runner)
|
||||
export type FlatCreateMyEntityAction = {
|
||||
type: 'create';
|
||||
metadataName: 'myEntity';
|
||||
flatEntity: FlatMyEntity;
|
||||
};
|
||||
|
||||
export type FlatUpdateMyEntityAction = {
|
||||
type: 'update';
|
||||
metadataName: 'myEntity';
|
||||
flatEntity: FlatMyEntity;
|
||||
updates: Partial<FlatMyEntity>;
|
||||
};
|
||||
|
||||
export type FlatDeleteMyEntityAction = {
|
||||
type: 'delete';
|
||||
metadataName: 'myEntity';
|
||||
flatEntity: FlatMyEntity;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Register in Central Constants
|
||||
|
||||
### 6a. AllFlatEntityTypesByMetadataName
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts`
|
||||
|
||||
```typescript
|
||||
export type AllFlatEntityTypesByMetadataName = {
|
||||
// ... existing entries
|
||||
myEntity: {
|
||||
flatEntityMaps: FlatMyEntityMaps;
|
||||
universalActions: {
|
||||
create: UniversalCreateMyEntityAction;
|
||||
update: UniversalUpdateMyEntityAction;
|
||||
delete: UniversalDeleteMyEntityAction;
|
||||
};
|
||||
flatActions: {
|
||||
create: FlatCreateMyEntityAction;
|
||||
update: FlatUpdateMyEntityAction;
|
||||
delete: FlatDeleteMyEntityAction;
|
||||
};
|
||||
flatEntity: FlatMyEntity;
|
||||
universalFlatEntity: UniversalFlatMyEntity;
|
||||
entity: MyEntityEntity;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 6b. ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts`
|
||||
|
||||
```typescript
|
||||
export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
// ... existing entries
|
||||
myEntity: {
|
||||
name: { toCompare: true },
|
||||
label: { toCompare: true },
|
||||
description: { toCompare: true },
|
||||
parentEntityId: {
|
||||
toCompare: true,
|
||||
universalProperty: 'parentEntityUniversalIdentifier',
|
||||
},
|
||||
settings: {
|
||||
toCompare: true,
|
||||
toStringify: true,
|
||||
universalProperty: 'universalSettings',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- `toCompare: true` → Editable property (checked for changes)
|
||||
- `toStringify: true` → JSONB/object property (needs JSON serialization)
|
||||
- `universalProperty` → Maps to universal version (for foreign keys & JSONB with `SerializedRelation`)
|
||||
|
||||
### 6c. ALL_ONE_TO_MANY_METADATA_RELATIONS
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts`
|
||||
|
||||
This constant is **type-checked** — values for `metadataName`, `flatEntityForeignKeyAggregator`, and `universalFlatEntityForeignKeyAggregator` are derived from entity type definitions. The aggregator names follow the pattern: remove trailing `'s'` from the relation property name, then append `Ids` or `UniversalIdentifiers`.
|
||||
|
||||
```typescript
|
||||
export const ALL_ONE_TO_MANY_METADATA_RELATIONS = {
|
||||
// ... existing entries
|
||||
myEntity: {
|
||||
// If myEntity has a `childEntities: ChildEntityEntity[]` property:
|
||||
childEntities: {
|
||||
metadataName: 'childEntity',
|
||||
flatEntityForeignKeyAggregator: 'childEntityIds',
|
||||
universalFlatEntityForeignKeyAggregator: 'childEntityUniversalIdentifiers',
|
||||
},
|
||||
// null for relations to non-syncable entities
|
||||
someNonSyncableRelation: null,
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 6d. ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts`
|
||||
|
||||
Low-level primitive constant. Only contains `foreignKey` — the column name ending in `Id` that stores the foreign key. Type-checked against entity properties.
|
||||
|
||||
```typescript
|
||||
export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
|
||||
// ... existing entries
|
||||
myEntity: {
|
||||
workspace: null,
|
||||
application: null,
|
||||
parentEntity: {
|
||||
foreignKey: 'parentEntityId',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 6e. ALL_MANY_TO_ONE_METADATA_RELATIONS
|
||||
|
||||
**File**: `src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts`
|
||||
|
||||
Derived from both `ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY` (for `foreignKey` type and `universalForeignKey` derivation) and `ALL_ONE_TO_MANY_METADATA_RELATIONS` (for `inverseOneToManyProperty` key constraint). This is the main constant consumed by utils and optimistic tooling.
|
||||
|
||||
```typescript
|
||||
export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
|
||||
// ... existing entries
|
||||
myEntity: {
|
||||
workspace: null,
|
||||
application: null,
|
||||
parentEntity: {
|
||||
metadataName: 'parentEntity',
|
||||
foreignKey: 'parentEntityId',
|
||||
inverseOneToManyProperty: 'myEntities', // key in ALL_ONE_TO_MANY_METADATA_RELATIONS['parentEntity'], or null if no inverse
|
||||
isNullable: false,
|
||||
universalForeignKey: 'parentEntityUniversalIdentifier',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Derivation dependency graph**:
|
||||
|
||||
```
|
||||
ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY ALL_ONE_TO_MANY_METADATA_RELATIONS
|
||||
(foreignKey only) (metadataName, aggregators)
|
||||
│ │
|
||||
│ FK type + universalFK derivation │ inverseOneToManyProperty keys
|
||||
│ │
|
||||
└────────────────┬───────────────────────┘
|
||||
▼
|
||||
ALL_MANY_TO_ONE_METADATA_RELATIONS
|
||||
(metadataName, foreignKey, inverseOneToManyProperty,
|
||||
isNullable, universalForeignKey)
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- `workspace: null`, `application: null` — always present, always null (non-syncable relations)
|
||||
- `inverseOneToManyProperty` — must be a key in `ALL_ONE_TO_MANY_METADATA_RELATIONS[targetMetadataName]`, or `null` if the target entity doesn't expose an inverse one-to-many relation
|
||||
- `universalForeignKey` — derived from `foreignKey` by replacing the `Id` suffix with `UniversalIdentifier`
|
||||
- Optimistic utils resolve `flatEntityForeignKeyAggregator` / `universalFlatEntityForeignKeyAggregator` at runtime by looking up `inverseOneToManyProperty` in `ALL_ONE_TO_MANY_METADATA_RELATIONS`
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before moving to Step 2:
|
||||
|
||||
- [ ] Metadata name added to `ALL_METADATA_NAME`
|
||||
- [ ] TypeORM entity created (extends `SyncableEntity`)
|
||||
- [ ] `isCustom` column added
|
||||
- [ ] Flat entity type defined
|
||||
- [ ] Flat entity maps type defined (if needed)
|
||||
- [ ] Editable properties constant defined
|
||||
- [ ] Universal and flat action types defined
|
||||
- [ ] Registered in `AllFlatEntityTypesByMetadataName`
|
||||
- [ ] Registered in `ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME`
|
||||
- [ ] Registered in `ALL_ONE_TO_MANY_METADATA_RELATIONS` (if entity has one-to-many relations)
|
||||
- [ ] Registered in `ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY`
|
||||
- [ ] Registered in `ALL_MANY_TO_ONE_METADATA_RELATIONS`
|
||||
- [ ] TypeScript compiles without errors
|
||||
|
||||
---
|
||||
|
||||
## Next Step
|
||||
|
||||
Once all types and constants are defined, proceed to:
|
||||
**[Syncable Entity: Cache & Transform (Step 2/6)](../syncable-entity-cache-and-transform/SKILL.md)**
|
||||
|
||||
For complete workflow, see `@creating-syncable-entity` rule.
|
||||
@@ -1,23 +0,0 @@
|
||||
#
|
||||
# Crowdin CLI configuration for App translations (twenty-front, twenty-server, twenty-emails)
|
||||
# Project ID: 1
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"preserve_hierarchy": true
|
||||
"base_path": ".."
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Documentation translations
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"project_id": 2
|
||||
"preserve_hierarchy": true
|
||||
"base_url": "https://twenty.api.crowdin.com"
|
||||
"base_path": ".."
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# MDX documentation files - user-guide
|
||||
# Using md type to preserve JSX component structure
|
||||
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
|
||||
#
|
||||
"source": "packages/twenty-docs/user-guide/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - developers
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/developers/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - twenty-ui
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# Navigation labels template - translated into per-locale navigation.json
|
||||
#
|
||||
"source": "packages/twenty-docs/navigation/navigation.template.json",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,8 +2,6 @@ version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
exclude-paths:
|
||||
- "packages/twenty-apps/community/**"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ runs:
|
||||
id: cache-primary-key-builder
|
||||
shell: bash
|
||||
run: |
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=v3-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Restore cache
|
||||
uses: actions/cache/restore@v4
|
||||
id: restore-cache
|
||||
@@ -32,4 +32,4 @@ runs:
|
||||
.nx
|
||||
node_modules/.cache
|
||||
packages/*/node_modules/.cache
|
||||
${{ inputs.additional-paths }}
|
||||
${{ inputs.additional-paths }}
|
||||
@@ -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
|
||||
@@ -63,9 +63,9 @@ jobs:
|
||||
- 8123:8123
|
||||
- 9000:9000
|
||||
options: >-
|
||||
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
@@ -78,12 +78,12 @@ jobs:
|
||||
id: merge_attempt
|
||||
run: |
|
||||
echo "Attempting to merge main into current branch..."
|
||||
|
||||
|
||||
git fetch origin main
|
||||
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
|
||||
|
||||
if git merge origin/main --no-edit; then
|
||||
echo "✅ Successfully merged main into current branch"
|
||||
echo "merged=true" >> $GITHUB_OUTPUT
|
||||
@@ -91,16 +91,16 @@ jobs:
|
||||
else
|
||||
echo "❌ Merge failed due to conflicts"
|
||||
echo "⚠️ Falling back to comparing current branch against main without merge"
|
||||
|
||||
|
||||
# Abort the failed merge
|
||||
git merge --abort
|
||||
|
||||
|
||||
echo "merged=false" >> $GITHUB_OUTPUT
|
||||
echo "BRANCH_STATE=conflicts" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build shared dependencies
|
||||
run: |
|
||||
@@ -128,22 +128,22 @@ jobs:
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
|
||||
echo "" >> "$env_file"
|
||||
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/current_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.CURRENT_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
@@ -166,19 +166,19 @@ jobs:
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Current branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
|
||||
echo "Current branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for current branch server to start"
|
||||
echo "Current server log:"
|
||||
@@ -188,15 +188,15 @@ jobs:
|
||||
|
||||
- name: Download GraphQL and REST responses from current branch
|
||||
run: |
|
||||
# Read admin token from shared test tokens file (single source of truth)
|
||||
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
|
||||
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
|
||||
echo "Downloading GraphQL schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
-o current-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
echo "Downloading GraphQL metadata schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -214,32 +214,32 @@ jobs:
|
||||
-o current-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
# Download current branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from current server..."
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
# Verify the downloads
|
||||
echo "Current branch files downloaded:"
|
||||
ls -la current-*
|
||||
|
||||
|
||||
|
||||
- name: Preserve current branch files
|
||||
run: |
|
||||
# Create a temp directory to store current branch files
|
||||
mkdir -p /tmp/current-branch-files
|
||||
|
||||
|
||||
# Move current branch files to temp directory
|
||||
mv current-* /tmp/current-branch-files/ 2>/dev/null || echo "No current-* files to preserve"
|
||||
|
||||
|
||||
echo "Preserved current branch files for later restoration"
|
||||
|
||||
- name: Stop current branch server
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
rm -rf node_modules packages/*/node_modules packages/*/dist dist .nx/cache
|
||||
|
||||
- name: Install dependencies for main branch
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build main branch dependencies
|
||||
run: |
|
||||
@@ -281,22 +281,22 @@ jobs:
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
|
||||
echo "" >> "$env_file"
|
||||
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
@@ -319,19 +319,19 @@ jobs:
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Main branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
|
||||
echo "Main branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for main branch server to start"
|
||||
echo "Main server log:"
|
||||
@@ -341,15 +341,15 @@ jobs:
|
||||
|
||||
- name: Download GraphQL and REST responses from main branch
|
||||
run: |
|
||||
# Read admin token from shared test tokens file (single source of truth)
|
||||
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
|
||||
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
|
||||
echo "Downloading GraphQL schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -358,7 +358,7 @@ jobs:
|
||||
-o main-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
echo "Downloading GraphQL metadata schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -367,33 +367,33 @@ jobs:
|
||||
-o main-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
|
||||
# Download main branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from main server..."
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
|
||||
# Verify the downloads
|
||||
echo "Main branch files downloaded:"
|
||||
ls -la main-*
|
||||
|
||||
|
||||
|
||||
- name: Restore current branch files
|
||||
run: |
|
||||
# Move current branch files back to working directory
|
||||
mv /tmp/current-branch-files/* . 2>/dev/null || echo "No files to restore"
|
||||
|
||||
|
||||
# Verify all files are present
|
||||
echo "All API files restored:"
|
||||
ls -la current-* main-* 2>/dev/null || echo "Some files may be missing"
|
||||
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf /tmp/current-branch-files
|
||||
|
||||
@@ -406,9 +406,9 @@ jobs:
|
||||
run: |
|
||||
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
|
||||
npm install -g @graphql-inspector/cli
|
||||
|
||||
|
||||
echo "=== GENERATING GRAPHQL DIFF REPORTS ==="
|
||||
|
||||
|
||||
# Check if GraphQL schema has changes
|
||||
echo "Checking GraphQL schema for changes..."
|
||||
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
echo "\`\`\`" >> graphql-schema-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
|
||||
# Check if GraphQL metadata schema has changes
|
||||
echo "Checking GraphQL metadata schema for changes..."
|
||||
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
|
||||
@@ -444,7 +444,7 @@ jobs:
|
||||
echo "\`\`\`" >> graphql-metadata-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
|
||||
# Show summary
|
||||
echo "Generated diff files:"
|
||||
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
|
||||
@@ -452,32 +452,32 @@ jobs:
|
||||
- name: Check REST API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
|
||||
|
||||
|
||||
# Use the Java-based openapi-diff via Docker
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-api-diff.json \
|
||||
/specs/main-rest-api.json /specs/current-rest-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-api-diff.json" ] && jq empty rest-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-api-diff.json)
|
||||
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST API"
|
||||
|
||||
|
||||
# Generate breaking changes report
|
||||
echo "# REST API Breaking Changes" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
@@ -493,7 +493,7 @@ jobs:
|
||||
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
|
||||
else "" end
|
||||
' rest-api-diff.json >> rest-api-diff.md
|
||||
|
||||
|
||||
elif [ "$different" = "true" ]; then
|
||||
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed) - no PR comment will be posted"
|
||||
# Don't create markdown file for non-breaking changes to avoid PR comments
|
||||
@@ -503,7 +503,7 @@ jobs:
|
||||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the files"
|
||||
|
||||
|
||||
echo "# REST API Analysis Error" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST API changes**" >> rest-api-diff.md
|
||||
@@ -512,7 +512,7 @@ jobs:
|
||||
echo "\`\`\`" >> rest-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-api.json /specs/current-rest-api.json 2>&1 >> rest-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-api-diff.md
|
||||
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST API analysis tool error - continuing workflow"
|
||||
fi
|
||||
@@ -520,33 +520,33 @@ jobs:
|
||||
- name: Check REST Metadata API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
|
||||
|
||||
|
||||
# Use the Java-based openapi-diff for metadata API as well
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-metadata-api-diff.json \
|
||||
/specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-metadata-api-diff.json" ] && jq empty rest-metadata-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-metadata-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-metadata-api-diff.json)
|
||||
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST Metadata API"
|
||||
|
||||
|
||||
# Generate breaking changes report (only for breaking changes)
|
||||
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
|
||||
@@ -570,7 +570,7 @@ jobs:
|
||||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the metadata API files"
|
||||
|
||||
|
||||
echo "# REST Metadata API Analysis Error" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST Metadata API changes**" >> rest-metadata-api-diff.md
|
||||
@@ -579,7 +579,7 @@ jobs:
|
||||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json 2>&1 >> rest-metadata-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
|
||||
fi
|
||||
@@ -592,7 +592,7 @@ jobs:
|
||||
const fs = require('fs');
|
||||
let hasChanges = false;
|
||||
let comment = '';
|
||||
|
||||
|
||||
try {
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
@@ -604,7 +604,7 @@ jobs:
|
||||
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.trim()) {
|
||||
@@ -615,7 +615,7 @@ jobs:
|
||||
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.trim()) {
|
||||
@@ -626,7 +626,7 @@ jobs:
|
||||
comment += restDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.trim()) {
|
||||
@@ -637,37 +637,37 @@ jobs:
|
||||
comment += metadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Only post comment if there are changes
|
||||
if (hasChanges) {
|
||||
// Add branch state information only if there were conflicts
|
||||
const branchState = process.env.BRANCH_STATE || 'unknown';
|
||||
let branchStateNote = '';
|
||||
|
||||
|
||||
if (branchState === 'conflicts') {
|
||||
branchStateNote = '\n\n⚠️ **Note**: Could not merge with `main` due to conflicts. This comparison shows changes between the current branch and `main` as separate states.\n';
|
||||
}
|
||||
// Check if there are any breaking changes detected
|
||||
let hasBreakingChanges = false;
|
||||
let breakingChangeNote = '';
|
||||
|
||||
|
||||
// Check for breaking changes in any of the diff files
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
|
||||
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
|
||||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
|
||||
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
|
||||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also check GraphQL changes for breaking changes indicators
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
@@ -675,18 +675,18 @@ jobs:
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check PR title for "breaking"
|
||||
const prTitle = ${{ toJSON(github.event.pull_request.title) }};
|
||||
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
|
||||
|
||||
|
||||
if (hasBreakingChanges) {
|
||||
if (titleContainsBreaking) {
|
||||
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
|
||||
@@ -702,20 +702,20 @@ jobs:
|
||||
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const commentBody = COMMENT_MARKER + '\n' + comment + branchStateNote + '\n⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
|
||||
|
||||
|
||||
// Get all comments to find existing API changes comment
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
|
||||
// Find our existing comment
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
@@ -737,17 +737,17 @@ jobs:
|
||||
}
|
||||
} else {
|
||||
console.log('No API changes detected - skipping PR comment');
|
||||
|
||||
|
||||
// Check if there's an existing comment to remove
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
@@ -780,7 +780,7 @@ jobs:
|
||||
/tmp/main-server.log
|
||||
/tmp/current-server.log
|
||||
*-api.json
|
||||
*-schema-introspection.json
|
||||
*-schema-introspection.json
|
||||
*-diff.md
|
||||
*-diff.json
|
||||
|
||||
|
||||
@@ -20,15 +20,14 @@ 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]
|
||||
task: [lint, typecheck, test, build]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
@@ -39,18 +38,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build create-twenty-app
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:create-app
|
||||
tasks: ${{ matrix.task }}
|
||||
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/cancel-workflow-action@0.11.0
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Docs / Lint English MDX files
|
||||
run: npx eslint "packages/twenty-docs/{developers,user-guide,twenty-ui,getting-started,snippets}/**/*.mdx" --max-warnings 0
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
name: CI E2E Playwright Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/**
|
||||
playwright.config.ts
|
||||
.github/workflows/ci-e2e.yaml
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true' && ( github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# https://github.com/actions/runner-images/issues/70#issuecomment-589562148
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: "true"
|
||||
SPILO_PROVIDER: "local"
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Check system resources
|
||||
run: |
|
||||
echo "Available memory:"
|
||||
free -h
|
||||
echo "Available disk space:"
|
||||
df -h
|
||||
echo "CPU info:"
|
||||
lscpu
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx nx setup twenty-e2e-testing
|
||||
|
||||
- name: Setup environment files
|
||||
run: |
|
||||
cp packages/twenty-front/.env.example packages/twenty-front/.env
|
||||
npx nx reset:env twenty-server
|
||||
|
||||
- name: Build frontend
|
||||
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
|
||||
|
||||
- name: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Start frontend
|
||||
run: |
|
||||
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
|
||||
echo "Waiting for frontend to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
|
||||
|
||||
- name: Start worker
|
||||
run: |
|
||||
npx nx run twenty-server:worker &
|
||||
echo "Worker started"
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npx nx test twenty-e2e-testing
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: packages/twenty-e2e-testing/run_results/
|
||||
retention-days: 30
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: packages/twenty-e2e-testing/playwright-report/
|
||||
retention-days: 30
|
||||
ci-e2e-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -25,14 +25,14 @@ jobs:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
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
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Build twenty-emails
|
||||
run: npx nx build twenty-emails
|
||||
- name: Run email tests
|
||||
@@ -40,25 +40,25 @@ jobs:
|
||||
# Start the email server in the background
|
||||
npx nx run twenty-emails:start &
|
||||
SERVER_PID=$!
|
||||
|
||||
|
||||
# Wait for server to start
|
||||
sleep 20
|
||||
|
||||
|
||||
# Check if server is running
|
||||
if ! curl -s http://localhost:4001/preview/test.email > /dev/null; then
|
||||
echo "Email server failed to start"
|
||||
kill $SERVER_PID
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Kill the server
|
||||
kill $SERVER_PID
|
||||
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
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
run: exit 1
|
||||
+34
-201
@@ -1,9 +1,11 @@
|
||||
name: CI Front and E2E
|
||||
name: CI Front
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
merge_group:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,9 +15,8 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-ubuntu-latest-8-cores-runner
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-ubuntu-latest-8-cores-runner-${{ github.ref_name }}-${{ github.sha }}
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
|
||||
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v3-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
@@ -27,22 +28,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:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
REACT_APP_SERVER_BASE_URL: http://localhost:3000
|
||||
steps:
|
||||
@@ -55,7 +45,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Diagnostic disk space issue
|
||||
run: df -h
|
||||
- name: Front / Write .env
|
||||
@@ -63,12 +53,12 @@ jobs:
|
||||
- name: Front / Build storybook
|
||||
run: npx nx storybook:build twenty-front
|
||||
- name: Save storybook build cache
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
|
||||
front-sb-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: front-sb-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -84,29 +74,19 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build dependencies
|
||||
run: |
|
||||
npx nx build twenty-shared
|
||||
npx nx build twenty-ui
|
||||
npx nx build twenty-sdk
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
cd packages/twenty-front
|
||||
npx playwright install
|
||||
run: cd packages/twenty-front && npx playwright install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Run storybook tests
|
||||
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
|
||||
run: npx nx storybook:serve-and-test:static twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }} --checkCoverage=false
|
||||
- name: Rename coverage file
|
||||
run: |
|
||||
if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
|
||||
mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
|
||||
else
|
||||
echo "Error: coverage-final.json not found"
|
||||
ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
|
||||
exit 1
|
||||
fi
|
||||
run: mv packages/twenty-front/coverage/storybook/coverage-storybook.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -115,7 +95,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
|
||||
@@ -127,7 +107,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
|
||||
@@ -141,9 +121,9 @@ jobs:
|
||||
run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
|
||||
front-chromatic-deployment:
|
||||
timeout-minutes: 30
|
||||
if: false
|
||||
if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push'
|
||||
needs: front-sb-build
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
|
||||
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
@@ -152,11 +132,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore storybook build cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
|
||||
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
|
||||
- name: Front / Write .env
|
||||
run: |
|
||||
cd packages/twenty-front
|
||||
@@ -169,9 +149,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:
|
||||
@@ -186,172 +165,35 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore ${{ matrix.task }} cache
|
||||
id: restore-task-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.TASK_CACHE_KEY }}
|
||||
- name: Reset .env
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: reset:env
|
||||
- name: Run ${{ matrix.task }} task
|
||||
id: run-task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
- name: Save ${{ matrix.task }} cache
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-task-cache.outputs.cache-primary-key }}
|
||||
front-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Build frontend
|
||||
run: npx nx build twenty-front
|
||||
- name: Upload frontend build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: packages/twenty-front/build
|
||||
retention-days: 1
|
||||
e2e-test:
|
||||
runs-on: depot-ubuntu-24.04
|
||||
needs: [changed-files-check-e2e, front-build]
|
||||
if: |
|
||||
always() &&
|
||||
needs.changed-files-check-e2e.outputs.any_changed == 'true' &&
|
||||
(needs.front-build.result == 'success' || needs.front-build.result == 'skipped') &&
|
||||
(github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: "true"
|
||||
SPILO_PROVIDER: "local"
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Check system resources
|
||||
run: |
|
||||
echo "Available memory:"
|
||||
free -h
|
||||
echo "Available disk space:"
|
||||
df -h
|
||||
echo "CPU info:"
|
||||
lscpu
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx nx setup twenty-e2e-testing
|
||||
|
||||
- name: Setup environment files
|
||||
run: |
|
||||
cp packages/twenty-front/.env.example packages/twenty-front/.env
|
||||
npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Download frontend build artifact
|
||||
if: needs.front-build.result == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: packages/twenty-front/build
|
||||
|
||||
- name: Build frontend (if not available from front-build)
|
||||
if: needs.front-build.result == 'skipped'
|
||||
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
|
||||
|
||||
- name: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Start frontend
|
||||
run: |
|
||||
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
|
||||
echo "Waiting for frontend to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
|
||||
|
||||
- name: Start worker
|
||||
run: |
|
||||
npx nx run twenty-server:worker &
|
||||
echo "Worker started"
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npx nx test twenty-e2e-testing
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: packages/twenty-e2e-testing/run_results/
|
||||
retention-days: 30
|
||||
|
||||
ci-front-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
[
|
||||
changed-files-check,
|
||||
front-task,
|
||||
front-build,
|
||||
front-chromatic-deployment,
|
||||
merge-reports-and-check-coverage,
|
||||
front-sb-test,
|
||||
front-sb-build,
|
||||
@@ -360,12 +202,3 @@ jobs:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
ci-e2e-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: depot-ubuntu-24.04
|
||||
needs: [changed-files-check-e2e, e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
@@ -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
|
||||
@@ -56,4 +56,4 @@ jobs:
|
||||
title: Release v${{ steps.sanitize.outputs.version }}
|
||||
labels: |
|
||||
release
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
name: CI SDK
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -18,15 +20,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, build]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
@@ -37,20 +38,15 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
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
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:sdk
|
||||
tasks: ${{ matrix.task }}
|
||||
sdk-e2e-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: [changed-files-check, sdk-test]
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
services:
|
||||
@@ -80,21 +76,25 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build twenty-sdk
|
||||
uses: ./.github/workflows/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
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:sdk
|
||||
tasks: test:e2e
|
||||
- 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
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, sdk-test, sdk-e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
name: CI Server
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
merge_group:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -31,7 +33,7 @@ jobs:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
@@ -57,16 +59,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
id: restore-server-setup-cache
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
- name: Server / Run lint & typecheck
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: lint,typecheck
|
||||
@@ -138,12 +140,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
- name: Save server setup
|
||||
uses: ./.github/actions/save-cache
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
|
||||
server-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: server-setup
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
@@ -151,25 +153,25 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Restore server setup
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Run Tests
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: test
|
||||
|
||||
server-integration-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: server-setup
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
@@ -207,14 +209,14 @@ jobs:
|
||||
ANALYTICS_ENABLED: true
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
SHARD_COUNTER: 8
|
||||
SHARD_COUNTER: 5
|
||||
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
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Update .env.test for integrations tests
|
||||
run: |
|
||||
echo "" >> .env.test
|
||||
@@ -224,7 +226,7 @@ jobs:
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Restore server setup
|
||||
uses: ./.github/actions/restore-cache
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
|
||||
- name: Server / Build
|
||||
@@ -241,7 +243,7 @@ jobs:
|
||||
- name: Run ClickHouse seeds
|
||||
run: npx nx clickhouse:seed twenty-server
|
||||
- name: Server / Run Integration Tests
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: 'test:integration'
|
||||
@@ -250,7 +252,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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
name: CI Shared
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -22,9 +24,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]
|
||||
@@ -38,16 +38,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:frontend
|
||||
tasks: ${{ matrix.task }}
|
||||
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
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: CI Docker Compose
|
||||
name: 'Test Docker Compose'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
@@ -23,7 +21,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 +90,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,25 +25,25 @@ 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
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Utils / Run Danger.js
|
||||
run: cd packages/twenty-utils && npx nx danger:ci
|
||||
env:
|
||||
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
|
||||
|
||||
|
||||
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
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Run congratulate-dangerfile.js
|
||||
run: cd packages/twenty-utils && npx nx danger:congratulate
|
||||
env:
|
||||
|
||||
@@ -4,7 +4,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -23,7 +25,7 @@ jobs:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 10
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
@@ -45,7 +47,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Server / Create DB
|
||||
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
@@ -65,7 +67,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})`
|
||||
});
|
||||
@@ -24,7 +24,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/twenty-docs/**'
|
||||
- '.github/crowdin-docs.yml'
|
||||
- 'crowdin.yml'
|
||||
- '.github/workflows/docs-i18n-pull.yaml'
|
||||
|
||||
concurrency:
|
||||
@@ -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
|
||||
@@ -42,14 +42,31 @@ jobs:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Setup i18n-docs branch
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Setup i18n branch
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
git fetch origin i18n-docs || true
|
||||
git checkout -B i18n-docs origin/i18n-docs || git checkout -b i18n-docs
|
||||
git fetch origin i18n || true
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
@@ -62,35 +79,26 @@ jobs:
|
||||
git add .
|
||||
git stash || true
|
||||
|
||||
# Install Crowdin CLI for downloading translations
|
||||
- name: Install Crowdin CLI
|
||||
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
run: npm install -g @crowdin/cli
|
||||
|
||||
# Pull docs translations from Crowdin one language at a time
|
||||
# This avoids build timeout issues when processing all languages at once
|
||||
- name: Pull translated docs from Crowdin
|
||||
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
run: |
|
||||
# Languages supported by Mintlify (see packages/twenty-docs/src/shared/supported-languages.ts)
|
||||
LANGUAGES="fr ar cs de es it ja ko pt ro ru tr zh-CN"
|
||||
|
||||
for lang in $LANGUAGES; do
|
||||
echo "=== Pulling translations for $lang ==="
|
||||
crowdin download \
|
||||
--config .github/crowdin-docs.yml \
|
||||
--token "$CROWDIN_PERSONAL_TOKEN" \
|
||||
--base-url "https://twenty.api.crowdin.com" \
|
||||
--language "$lang" \
|
||||
--skip-untranslated-strings=false \
|
||||
--skip-untranslated-files=false \
|
||||
--export-only-approved=false \
|
||||
--verbose || echo "Warning: Failed to pull $lang, continuing with other languages..."
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "=== Download complete ==="
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: 'packages/twenty-docs/**/*.mdx'
|
||||
translation: 'packages/twenty-docs/l/%two_letters_code%/**/%original_file_name%'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
skip_untranslated_files: true
|
||||
push_translations: false
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Fix file permissions
|
||||
@@ -107,13 +115,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
|
||||
@@ -137,13 +142,13 @@ jobs:
|
||||
|
||||
- name: Push changes
|
||||
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n-docs
|
||||
run: git push origin HEAD:i18n
|
||||
|
||||
- name: Create pull request
|
||||
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
if git diff --name-only origin/main..HEAD | grep -q .; then
|
||||
gh pr create -B main -H i18n-docs --title 'i18n - docs translations' --body 'Created by Github action' || true
|
||||
gh pr create -B main -H i18n --title 'i18n - docs translations' --body 'Created by Github action' || true
|
||||
else
|
||||
echo "No file differences between branches, skipping PR creation"
|
||||
fi
|
||||
|
||||
@@ -7,12 +7,11 @@ on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ['main']
|
||||
branches: ['main', 'docs-localized-navigation']
|
||||
paths:
|
||||
- 'packages/twenty-docs/**/*.mdx'
|
||||
- '!packages/twenty-docs/l/**'
|
||||
- 'packages/twenty-docs/navigation/navigation.template.json'
|
||||
- '.github/crowdin-docs.yml'
|
||||
- '!packages/twenty-docs/fr/**'
|
||||
- 'crowdin.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@@ -21,7 +20,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
|
||||
@@ -29,8 +28,28 @@ jobs:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Generate navigation template for Crowdin
|
||||
run: yarn docs:generate-navigation-template
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Generate navigation template for Crowdin
|
||||
run: yarn docs:generate-navigation-template
|
||||
@@ -41,11 +60,9 @@ jobs:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
localization_branch_name: i18n-docs
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: '.github/crowdin-docs.yml'
|
||||
env:
|
||||
# Docs translations project
|
||||
CROWDIN_PROJECT_ID: '2'
|
||||
CROWDIN_PROJECT_ID: 1
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
@@ -85,14 +85,12 @@ jobs:
|
||||
push_sources: false
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
push_translations: false
|
||||
push_translations: true
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
config: '.github/crowdin-app.yml'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# App translations project
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -101,12 +99,6 @@ jobs:
|
||||
- name: Fix file permissions
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
# Fix encoding issues (escaped Unicode like \u62db -> 招) and push fixes back to Crowdin
|
||||
- name: Fix translation encoding and sync to Crowdin
|
||||
run: npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Compile translations
|
||||
id: compile_translations
|
||||
# Because we have set English as a fallback locale, this condition does not work anymore
|
||||
@@ -116,6 +108,8 @@ jobs:
|
||||
npx nx run twenty-emails:lingui:compile
|
||||
npx nx run twenty-front:lingui:compile
|
||||
git status
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add .
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile translations"
|
||||
|
||||
@@ -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
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
git checkout -B i18n origin/i18n || git checkout -b i18n
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
@@ -87,10 +87,11 @@ jobs:
|
||||
download_translations: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: '.github/crowdin-app.yml'
|
||||
env:
|
||||
# App translations project
|
||||
CROWDIN_PROJECT_ID: '1'
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: 1
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: Create a pull request
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# Weekly translation QA report using Crowdin's native QA checks
|
||||
|
||||
name: 'Weekly Translation QA Report'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Every Monday at 9am UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qa_report:
|
||||
name: Generate QA Report
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Generate QA report from Crowdin
|
||||
id: generate_report
|
||||
run: |
|
||||
npx ts-node packages/twenty-utils/translation-qa-report.ts || true
|
||||
if [ -f TRANSLATION_QA_REPORT.md ]; then
|
||||
echo "report_generated=true" >> $GITHUB_OUTPUT
|
||||
# Count critical issues (exclude spellcheck)
|
||||
CRITICAL=$(grep -oP '⚠️\s+\K\d+' TRANSLATION_QA_REPORT.md 2>/dev/null || echo "0")
|
||||
echo "critical_issues=$CRITICAL" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "report_generated=false" >> $GITHUB_OUTPUT
|
||||
echo "critical_issues=0" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Create QA branch and commit report
|
||||
if: steps.generate_report.outputs.report_generated == 'true'
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
|
||||
BRANCH_NAME="i18n-qa-report-$(date +%Y-%m-%d)"
|
||||
git checkout -B $BRANCH_NAME
|
||||
|
||||
git add TRANSLATION_QA_REPORT.md
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "docs: weekly translation QA report"
|
||||
git push origin HEAD:$BRANCH_NAME --force
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
else
|
||||
echo "No changes to commit"
|
||||
echo "BRANCH_NAME=" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.generate_report.outputs.report_generated == 'true' && env.BRANCH_NAME != ''
|
||||
run: |
|
||||
CRITICAL="${{ steps.generate_report.outputs.critical_issues }}"
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
## Weekly Translation QA Report
|
||||
|
||||
**Critical issues (excluding spellcheck): $CRITICAL**
|
||||
|
||||
📊 **View in Crowdin**: https://twenty.crowdin.com/u/projects/1/all?filter=qa-issue
|
||||
|
||||
### For AI-Assisted Fixing
|
||||
|
||||
Open this PR in Cursor and say:
|
||||
|
||||
> "Fix the translation QA issues using the Crowdin API"
|
||||
|
||||
The AI can help fix:
|
||||
- ✅ Variables mismatch (missing/wrong placeholders)
|
||||
- ✅ Escaped Unicode sequences
|
||||
- ⚠️ Tags mismatch
|
||||
- ⚠️ Empty translations
|
||||
|
||||
### Available Scripts
|
||||
|
||||
\`\`\`bash
|
||||
# View QA report
|
||||
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/translation-qa-report.ts
|
||||
|
||||
# Fix encoding issues automatically
|
||||
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
*Close without merging after issues are addressed*
|
||||
EOF
|
||||
)
|
||||
|
||||
EXISTING_PR=$(gh pr list --head $BRANCH_NAME --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
gh pr edit $EXISTING_PR --body "$BODY"
|
||||
else
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head $BRANCH_NAME \
|
||||
--title "i18n: Translation QA Report ($CRITICAL critical issues)" \
|
||||
--body "$BODY" || true
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -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
|
||||
|
||||
@@ -11,13 +11,13 @@ on:
|
||||
jobs:
|
||||
preview-environment:
|
||||
timeout-minutes: 310
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
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/expose-tunnel@v1.5.0
|
||||
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/*
|
||||
@@ -49,5 +48,3 @@ dump.rdb
|
||||
|
||||
mcp.json
|
||||
/.junie/
|
||||
TRANSLATION_QA_REPORT.md
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
"mcpServers": {
|
||||
"postgres": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
|
||||
"env": {}
|
||||
"command": "uv",
|
||||
"args": ["run", "postgres-mcp", "--access-mode=unrestricted"],
|
||||
"env": {
|
||||
"DATABASE_URI": "${PG_DATABASE_URL}"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
|
||||
+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');
|
||||
@@ -0,0 +1,4 @@
|
||||
# Add files here to ignore them from prettier formatting
|
||||
/dist
|
||||
/coverage
|
||||
/.nx/cache
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
Vendored
+1
-12
@@ -68,14 +68,12 @@
|
||||
"./jest-integration.config.ts",
|
||||
"${relativeFile}",
|
||||
"--silent=false",
|
||||
"${input:updateSnapshot}"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/packages/twenty-server",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"env": {
|
||||
"NODE_ENV": "test",
|
||||
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
|
||||
"NODE_ENV": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -99,14 +97,5 @@
|
||||
"NODE_ENV": "test"
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "updateSnapshot",
|
||||
"type": "pickString",
|
||||
"description": "Update snapshots?",
|
||||
"options": ["", "--updateSnapshot"],
|
||||
"default": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
-3
@@ -31,7 +31,6 @@
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"javascript.format.enable": false,
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.format.enable": false,
|
||||
"cSpell.enableFiletypes": [
|
||||
"!javascript",
|
||||
@@ -53,6 +52,5 @@
|
||||
".cursorrules": "markdown"
|
||||
},
|
||||
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.experimental.useTsgo": false
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
Vendored
+7
-59
@@ -2,64 +2,12 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "twenty-server - run integration test file",
|
||||
"type": "shell",
|
||||
"command": "npx nx run twenty-server:jest -- --config ./jest-integration.config.ts ${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": []
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"id": "watchMode",
|
||||
"type": "pickString",
|
||||
"description": "Enable watch mode?",
|
||||
"options": ["", "--watch"],
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "updateSnapshot",
|
||||
"type": "pickString",
|
||||
"description": "Update snapshots?",
|
||||
"options": ["", "--updateSnapshot"],
|
||||
"default": ""
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"path": "server",
|
||||
"problemMatcher": [],
|
||||
"label": "yarn: start - server",
|
||||
"detail": "yarn start"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -21,33 +21,25 @@ 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:serve-and-test:static 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 (full project - slower, use only when needed)
|
||||
npx nx lint twenty-front
|
||||
npx nx lint twenty-server
|
||||
# Linting
|
||||
npx nx lint twenty-front # Frontend linting
|
||||
npx nx lint twenty-server # Backend linting
|
||||
npx nx lint twenty-front --fix # Auto-fix linting issues
|
||||
|
||||
# Type checking
|
||||
npx nx typecheck twenty-front
|
||||
@@ -60,8 +52,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 +64,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 +73,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 +102,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 +117,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
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
DOCKER_NETWORK=twenty_network
|
||||
|
||||
ensure-docker-network:
|
||||
docker network inspect $(DOCKER_NETWORK) >/dev/null 2>&1 || docker network create $(DOCKER_NETWORK)
|
||||
|
||||
postgres-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) \
|
||||
--name twenty_pg \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e ALLOW_NOSSL=true \
|
||||
-v twenty_db_data:/var/lib/postgresql/data \
|
||||
-p 5432:5432 \
|
||||
postgres:16
|
||||
@echo "Waiting for PostgreSQL to be ready..."
|
||||
@until docker exec twenty_pg psql -U postgres -d postgres \
|
||||
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
|
||||
sleep 1; \
|
||||
done
|
||||
docker exec twenty_pg psql -U postgres -d postgres \
|
||||
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
|
||||
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
|
||||
|
||||
redis-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
|
||||
|
||||
clickhouse-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest \
|
||||
|
||||
grafana-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) \
|
||||
--name twenty_grafana \
|
||||
-p 4000:3000 \
|
||||
-e GF_SECURITY_ADMIN_USER=admin \
|
||||
-e GF_SECURITY_ADMIN_PASSWORD=admin \
|
||||
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
|
||||
-v $(PWD)/packages/twenty-docker/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources \
|
||||
grafana/grafana-oss:latest
|
||||
|
||||
opentelemetry-collector-on-docker: ensure-docker-network
|
||||
docker run -d --network $(DOCKER_NETWORK) \
|
||||
--name twenty_otlp_collector \
|
||||
-p 4317:4317 \
|
||||
-p 4318:4318 \
|
||||
-p 13133:13133 \
|
||||
-v $(PWD)/packages/twenty-docker/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml \
|
||||
otel/opentelemetry-collector-contrib:latest \
|
||||
--config /etc/otel-collector-config.yaml
|
||||
@@ -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).
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# Basic Crowdin CLI configuration
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
# See https://support.crowdin.com/developer/configuration-file/ for all available options
|
||||
#
|
||||
|
||||
#
|
||||
# Defines whether to preserve the original directory structure in the Crowdin project
|
||||
# Recommended to set to true
|
||||
#
|
||||
"preserve_hierarchy": true
|
||||
|
||||
#
|
||||
# Files configuration.
|
||||
# See https://support.crowdin.com/developer/configuration-file/ for all available options
|
||||
#
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter
|
||||
# e.g. "/resources/en/*.json"
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
#
|
||||
# Translation files filter
|
||||
# e.g. "/resources/%two_letters_code%/%original_file_name%"
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - user-guide
|
||||
# Using md type to preserve JSX component structure
|
||||
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
|
||||
#
|
||||
"source": "packages/twenty-docs/user-guide/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - developers
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/developers/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# MDX documentation files - twenty-ui
|
||||
# Using md type to preserve JSX component structure
|
||||
#
|
||||
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
|
||||
},
|
||||
{
|
||||
#
|
||||
# Navigation labels template - translated into per-locale navigation.json
|
||||
#
|
||||
"source": "packages/twenty-docs/navigation/navigation.template.json",
|
||||
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
|
||||
}
|
||||
]
|
||||
+8
-13
@@ -11,10 +11,6 @@ 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',
|
||||
);
|
||||
|
||||
export default [
|
||||
// Base JavaScript configuration
|
||||
js.configs.recommended,
|
||||
@@ -67,10 +63,6 @@ export default [
|
||||
sourceTag: 'scope:sdk',
|
||||
onlyDependOnLibsWithTags: ['scope:sdk', 'scope:shared'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:create-app',
|
||||
onlyDependOnLibsWithTags: ['scope:create-app', 'scope:shared'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:shared',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
@@ -83,6 +75,10 @@ export default [
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -145,10 +141,10 @@ export default [
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowInterfaces: 'with-single-extends',
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
@@ -203,7 +199,6 @@ export default [
|
||||
plugins: {
|
||||
...mdxPlugin.flat.plugins,
|
||||
'@nx': nxPlugin,
|
||||
twenty: { rules: twentyRules },
|
||||
},
|
||||
},
|
||||
mdxPlugin.flatCodeBlocks,
|
||||
@@ -214,9 +209,9 @@ export default [
|
||||
'unused-imports/no-unused-imports': 'off',
|
||||
'unused-imports/no-unused-vars': 'off',
|
||||
// Enforce JSX tags on separate lines to prevent Crowdin translation issues
|
||||
'twenty/mdx-component-newlines': 'error',
|
||||
'@nx/workspace-mdx-component-newlines': 'error',
|
||||
// Disallow angle bracket placeholders to prevent Crowdin translation errors
|
||||
'twenty/no-angle-bracket-placeholders': 'error',
|
||||
'@nx/workspace-no-angle-bracket-placeholders': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import js from '@eslint/js';
|
||||
import nxPlugin from '@nx/eslint-plugin';
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import linguiPlugin from 'eslint-plugin-lingui';
|
||||
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
|
||||
import prettierPlugin from 'eslint-plugin-prettier';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
|
||||
import unicornPlugin from 'eslint-plugin-unicorn';
|
||||
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
|
||||
import jsoncParser from 'jsonc-eslint-parser';
|
||||
|
||||
export default [
|
||||
// Base JavaScript configuration
|
||||
js.configs.recommended,
|
||||
|
||||
// Lingui recommended rules
|
||||
linguiPlugin.configs['flat/recommended'],
|
||||
|
||||
// Base configuration for all files
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
'react': reactPlugin,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
'react-refresh': reactRefreshPlugin,
|
||||
'prettier': prettierPlugin,
|
||||
'lingui': linguiPlugin,
|
||||
'@nx': nxPlugin,
|
||||
'prefer-arrow': preferArrowPlugin,
|
||||
'import': importPlugin,
|
||||
'unused-imports': unusedImportsPlugin,
|
||||
'unicorn': unicornPlugin,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// General rules
|
||||
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
|
||||
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
|
||||
'no-control-regex': 0,
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// Nx rules
|
||||
'@nx/enforce-module-boundaries': [
|
||||
'error',
|
||||
{
|
||||
enforceBuildableLibDependency: true,
|
||||
allow: [],
|
||||
depConstraints: [
|
||||
{
|
||||
sourceTag: 'scope:shared',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:backend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// Import rules
|
||||
'import/no-relative-packages': 'error',
|
||||
'import/no-useless-path-segments': 'error',
|
||||
'import/no-duplicates': ['error', { considerQueryString: true }],
|
||||
|
||||
// Prefer arrow functions
|
||||
'prefer-arrow/prefer-arrow-functions': [
|
||||
'error',
|
||||
{
|
||||
disallowPrototype: true,
|
||||
singleReturnOnly: false,
|
||||
classPropertiesAllowed: false,
|
||||
},
|
||||
],
|
||||
|
||||
// Unused imports
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
// React rules
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react/jsx-key': 'off',
|
||||
'react/display-name': 'off',
|
||||
'react/jsx-uses-react': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/jsx-no-useless-fragment': 'off',
|
||||
'react/jsx-props-no-spreading': [
|
||||
'error',
|
||||
{
|
||||
explicitSpread: 'ignore',
|
||||
},
|
||||
],
|
||||
|
||||
// React hooks rules
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: 'useRecoilCallback',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// TypeScript specific configuration
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
// Note: project path should be specified by each package individually
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
},
|
||||
rules: {
|
||||
// Import restrictions
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@tabler/icons-react'],
|
||||
message: 'Please import icons from `twenty-ui`',
|
||||
},
|
||||
{
|
||||
group: ['react-hotkeys-web-hook'],
|
||||
importNames: ['useHotkeys'],
|
||||
message: 'Please use the custom wrapper: `useScopedHotkeys` from `twenty-ui`',
|
||||
},
|
||||
{
|
||||
group: ['lodash'],
|
||||
message: "Please use the standalone lodash package (for instance: `import groupBy from 'lodash.groupby'` instead of `import { groupBy } from 'lodash'`)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// TypeScript rules
|
||||
'no-redeclare': 'off', // Turn off base rule for TypeScript
|
||||
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
|
||||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports'
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
// Custom workspace rules
|
||||
'@nx/workspace-effect-components': 'error',
|
||||
'@nx/workspace-no-hardcoded-colors': 'error',
|
||||
'@nx/workspace-matching-state-variable': 'error',
|
||||
'@nx/workspace-sort-css-properties-alphabetically': 'error',
|
||||
'@nx/workspace-styled-components-prefixed-with-styled': 'error',
|
||||
'@nx/workspace-no-state-useref': 'error',
|
||||
'@nx/workspace-component-props-naming': 'error',
|
||||
'@nx/workspace-explicit-boolean-predicates-in-if': 'error',
|
||||
'@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error',
|
||||
'@nx/workspace-useRecoilCallback-has-dependency-array': 'error',
|
||||
'@nx/workspace-no-navigate-prefer-link': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// Storybook files
|
||||
{
|
||||
files: ['*.stories.@(ts|tsx|js|jsx)'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// JavaScript specific configuration
|
||||
{
|
||||
files: ['*.{js,jsx}'],
|
||||
rules: {
|
||||
// JavaScript-specific rules if needed
|
||||
},
|
||||
},
|
||||
|
||||
// Constants files
|
||||
{
|
||||
files: ['**/constants/*.ts', '**/*.constants.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['UPPER_CASE'],
|
||||
},
|
||||
],
|
||||
'unicorn/filename-case': [
|
||||
'warn',
|
||||
{
|
||||
cases: {
|
||||
pascalCase: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
|
||||
// Test files
|
||||
{
|
||||
files: [
|
||||
'*.test.@(ts|tsx|js|jsx)',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
jest: true,
|
||||
describe: true,
|
||||
it: true,
|
||||
expect: true,
|
||||
beforeEach: true,
|
||||
afterEach: true,
|
||||
beforeAll: true,
|
||||
afterAll: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Constants files
|
||||
{
|
||||
files: ['**/*.constants.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['UPPER_CASE'],
|
||||
},
|
||||
],
|
||||
'unicorn/filename-case': [
|
||||
'warn',
|
||||
{
|
||||
cases: {
|
||||
pascalCase: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
|
||||
// JSON files
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
languageOptions: {
|
||||
parser: jsoncParser,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getJestProjects } from '@nx/jest';
|
||||
|
||||
export default {
|
||||
projects: getJestProjects(),
|
||||
};
|
||||
+1
-7
@@ -1,9 +1,3 @@
|
||||
const nxPreset = require('@nx/jest/preset').default;
|
||||
|
||||
module.exports = {
|
||||
...nxPreset,
|
||||
// Override the new testEnvironmentOptions added in @nx/jest 22.3.3
|
||||
// which breaks Lingui's module resolution
|
||||
testEnvironmentOptions: {},
|
||||
};
|
||||
|
||||
module.exports = { ...nxPreset };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
command -v node >/dev/null 2>&1 || { echo >&2 "Nx requires NodeJS to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
|
||||
command -v npm >/dev/null 2>&1 || { echo >&2 "Nx requires npm to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
|
||||
path_to_root=$(dirname $BASH_SOURCE)
|
||||
node $path_to_root/.nx/nxw.js $@
|
||||
@@ -58,19 +58,6 @@
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"lint:diff-with-main": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": false,
|
||||
"options": {
|
||||
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs \"$@\"; fi' _",
|
||||
"pattern": "\\.(ts|tsx|js|jsx)$"
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs --fix \"$@\"; fi' _"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
@@ -97,11 +84,11 @@
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "tsgo -p tsconfig.json"
|
||||
"command": "tsc -b tsconfig.json --incremental"
|
||||
},
|
||||
"configurations": {
|
||||
"watch": {
|
||||
"command": "tsgo -p tsconfig.json --watch"
|
||||
"watch": true
|
||||
}
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
@@ -118,7 +105,6 @@
|
||||
"outputs": ["{projectRoot}/coverage"],
|
||||
"options": {
|
||||
"jestConfig": "{projectRoot}/jest.config.mjs",
|
||||
"silent": true,
|
||||
"coverage": true,
|
||||
"coverageReporters": ["text-summary"],
|
||||
"cacheDirectory": "../../.cache/jest/{projectRoot}"
|
||||
@@ -147,7 +133,7 @@
|
||||
"outputs": ["{projectRoot}/{options.output-dir}"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "NODE_OPTIONS='--max-old-space-size=10240' VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
|
||||
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
|
||||
"output-dir": "storybook-static",
|
||||
"config-dir": ".storybook"
|
||||
},
|
||||
@@ -182,8 +168,15 @@
|
||||
"outputs": ["{projectRoot}/coverage/storybook"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "vitest run --coverage --shard={args.shard}",
|
||||
"shard": "1/1"
|
||||
"commands": [
|
||||
"test-storybook --url http://localhost:{args.port} --maxWorkers=3 --coverage --coverageDirectory={args.coverageDir} --shard={args.shard}",
|
||||
"nx storybook:coverage {projectName} --coverageDir={args.coverageDir} --checkCoverage={args.checkCoverage}"
|
||||
],
|
||||
"shard": "1/1",
|
||||
"parallel": false,
|
||||
"coverageDir": "coverage/storybook",
|
||||
"port": 6006,
|
||||
"checkCoverage": true
|
||||
}
|
||||
},
|
||||
"storybook:test:no-coverage": {
|
||||
@@ -191,8 +184,10 @@
|
||||
"inputs": ["^default", "excludeTests"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "vitest run --shard={args.shard}",
|
||||
"shard": "1/1"
|
||||
"commands": [
|
||||
"test-storybook --url http://localhost:{args.port} --maxWorkers=2"
|
||||
],
|
||||
"port": 6006
|
||||
}
|
||||
},
|
||||
"storybook:coverage": {
|
||||
@@ -219,6 +214,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"storybook:serve-and-test:static": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"commands": [
|
||||
"npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --shard={args.shard} --checkCoverage={args.checkCoverage} --port={args.port} --configuration={args.scope}'"
|
||||
],
|
||||
"shard": "1/1",
|
||||
"checkCoverage": true,
|
||||
"port": 6006
|
||||
}
|
||||
},
|
||||
"chromatic": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
@@ -260,19 +266,22 @@
|
||||
"inputs": [
|
||||
"default",
|
||||
"{workspaceRoot}/eslint.config.mjs",
|
||||
"{workspaceRoot}/packages/twenty-eslint-rules/**/*"
|
||||
"{workspaceRoot}/tools/eslint-rules/**/*"
|
||||
]
|
||||
},
|
||||
"@nx/vite:test": {
|
||||
"cache": true,
|
||||
"inputs": ["default", "^default"]
|
||||
},
|
||||
"@nx/vite:build": {
|
||||
"cache": true,
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["default", "^default"]
|
||||
},
|
||||
"@nx/vitest:test": {
|
||||
"cache": true,
|
||||
"inputs": ["default", "^default"]
|
||||
}
|
||||
},
|
||||
"installation": {
|
||||
"version": "22.0.3"
|
||||
},
|
||||
"generators": {
|
||||
"@nx/react": {
|
||||
"application": {
|
||||
|
||||
+40
-48
@@ -2,6 +2,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.17",
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@floating-ui/react": "^0.24.3",
|
||||
@@ -22,7 +23,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",
|
||||
@@ -53,7 +53,7 @@
|
||||
"rxjs": "^7.2.0",
|
||||
"semver": "^7.5.4",
|
||||
"slash": "^5.1.0",
|
||||
"temporal-polyfill": "^0.3.0",
|
||||
"storybook-addon-mock-date": "^0.6.0",
|
||||
"ts-key-enum": "^2.0.12",
|
||||
"tslib": "^2.8.1",
|
||||
"type-fest": "4.10.1",
|
||||
@@ -67,36 +67,41 @@
|
||||
"@babel/core": "^7.14.5",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@babel/preset-typescript": "^7.24.6",
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@chromatic-com/storybook": "^3",
|
||||
"@graphql-codegen/cli": "^3.3.1",
|
||||
"@graphql-codegen/client-preset": "^4.1.0",
|
||||
"@graphql-codegen/typescript": "^3.0.4",
|
||||
"@graphql-codegen/typescript-operations": "^3.0.4",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||
"@nx/eslint": "22.3.3",
|
||||
"@nx/eslint-plugin": "22.3.3",
|
||||
"@nx/jest": "22.3.3",
|
||||
"@nx/js": "22.3.3",
|
||||
"@nx/react": "22.3.3",
|
||||
"@nx/storybook": "22.3.3",
|
||||
"@nx/vite": "22.3.3",
|
||||
"@nx/web": "22.3.3",
|
||||
"@nx/eslint": "22.0.3",
|
||||
"@nx/eslint-plugin": "22.0.3",
|
||||
"@nx/jest": "22.0.3",
|
||||
"@nx/js": "22.0.3",
|
||||
"@nx/react": "22.0.3",
|
||||
"@nx/storybook": "22.0.3",
|
||||
"@nx/vite": "22.0.3",
|
||||
"@nx/web": "22.0.3",
|
||||
"@sentry/types": "^8",
|
||||
"@storybook-community/storybook-addon-cookie": "^5.0.0",
|
||||
"@storybook/addon-coverage": "^3.0.0",
|
||||
"@storybook/addon-docs": "^10.1.11",
|
||||
"@storybook/addon-links": "^10.1.11",
|
||||
"@storybook/addon-vitest": "^10.1.11",
|
||||
"@storybook/icons": "^2.0.1",
|
||||
"@storybook/react-vite": "^10.1.11",
|
||||
"@storybook/test-runner": "^0.24.2",
|
||||
"@storybook/addon-actions": "8.6.14",
|
||||
"@storybook/addon-coverage": "^1.0.0",
|
||||
"@storybook/addon-essentials": "8.6.14",
|
||||
"@storybook/addon-interactions": "8.6.14",
|
||||
"@storybook/addon-links": "8.6.14",
|
||||
"@storybook/blocks": "8.6.14",
|
||||
"@storybook/core-server": "8.6.14",
|
||||
"@storybook/icons": "^1.2.9",
|
||||
"@storybook/preview-api": "8.6.14",
|
||||
"@storybook/react": "8.6.14",
|
||||
"@storybook/react-vite": "8.6.14",
|
||||
"@storybook/test": "8.6.14",
|
||||
"@storybook/test-runner": "^0.23.0",
|
||||
"@storybook/types": "8.6.14",
|
||||
"@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",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
@@ -104,6 +109,7 @@
|
||||
"@types/bytes": "^3.1.1",
|
||||
"@types/chrome": "^0.0.267",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/graphql-fields": "^1.3.6",
|
||||
"@types/inquirer": "^9.0.9",
|
||||
@@ -136,11 +142,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.39.0",
|
||||
"@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",
|
||||
"@yarnpkg/types": "^4.0.0",
|
||||
"chromatic": "^6.18.0",
|
||||
"concurrently": "^8.2.2",
|
||||
@@ -160,7 +162,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"eslint-plugin-storybook": "^10.1.11",
|
||||
"eslint-plugin-storybook": "^0.9.0",
|
||||
"eslint-plugin-unicorn": "^56.0.1",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"http-server": "^14.1.1",
|
||||
@@ -169,25 +171,23 @@
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jsdom": "~22.1.0",
|
||||
"msw": "^2.12.7",
|
||||
"msw-storybook-addon": "^2.0.6",
|
||||
"nx": "22.3.3",
|
||||
"msw": "^2.0.11",
|
||||
"msw-storybook-addon": "^2.0.5",
|
||||
"nx": "22.0.3",
|
||||
"prettier": "^3.1.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.5",
|
||||
"source-map-support": "^0.5.20",
|
||||
"storybook": "^10.1.11",
|
||||
"storybook-addon-mock-date": "2.0.0",
|
||||
"storybook-addon-pseudo-states": "^10.1.11",
|
||||
"storybook": "8.6.14",
|
||||
"storybook-addon-cookie": "^3.2.0",
|
||||
"storybook-addon-pseudo-states": "^2.1.2",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.17.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -203,16 +203,13 @@
|
||||
"typescript": "5.9.2",
|
||||
"graphql-redis-subscriptions/ioredis": "^5.6.0",
|
||||
"prosemirror-view": "1.40.0",
|
||||
"prosemirror-transform": "1.10.4",
|
||||
"@lingui/core": "5.1.2",
|
||||
"@types/qs": "6.9.16"
|
||||
"prosemirror-transform": "1.10.4"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"nx": {},
|
||||
"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": {
|
||||
@@ -231,12 +228,7 @@
|
||||
"packages/twenty-apps",
|
||||
"packages/twenty-cli",
|
||||
"packages/create-twenty-app",
|
||||
"packages/twenty-eslint-rules"
|
||||
"tools/eslint-rules"
|
||||
]
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,106 +15,53 @@
|
||||
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)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# If you don't use yarn@4
|
||||
corepack enable
|
||||
yarn install
|
||||
|
||||
# Get help and list all available commands
|
||||
yarn twenty help
|
||||
|
||||
# Authenticate using your API key (you'll be prompted)
|
||||
yarn twenty auth:login
|
||||
yarn auth
|
||||
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty entity:add
|
||||
yarn create-entity
|
||||
|
||||
# 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 generate
|
||||
|
||||
# Watch your application's function logs
|
||||
yarn twenty function:logs
|
||||
# Start dev mode: automatically syncs local changes to your workspace
|
||||
yarn 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 sync
|
||||
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
# Watch your application's functions logs
|
||||
yarn logs
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
yarn uninstall
|
||||
|
||||
# Display commands' help
|
||||
yarn help
|
||||
```
|
||||
|
||||
## 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 create-entity`.
|
||||
- Keep your types up‑to‑date using `yarn generate`.
|
||||
- Use `yarn dev` while you iterate to see changes instantly in your workspace.
|
||||
|
||||
|
||||
## Publish your application
|
||||
@@ -142,8 +89,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` again and verify the API key permissions.
|
||||
- Types not generated: ensure `yarn generate` runs without errors, then re‑start `yarn 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.2.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"package.json"
|
||||
"dist/**/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npx rimraf dist && npx vite build"
|
||||
@@ -45,9 +43,6 @@
|
||||
"@types/lodash.kebabcase": "^4.1.7",
|
||||
"@types/lodash.startcase": "^4",
|
||||
"@types/node": "^20.0.0",
|
||||
"twenty-sdk": "workspace:*",
|
||||
"twenty-shared": "workspace:*",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1 +1,3 @@
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
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.
|
||||
@@ -5,41 +5,17 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty auth:login
|
||||
yarn auth
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
Then, install this app to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty app:dev
|
||||
yarn 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
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -1,29 +1,137 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import typescriptParser from '@typescript-eslint/parser';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
|
||||
import prettierPlugin from 'eslint-plugin-prettier';
|
||||
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
|
||||
|
||||
export default [
|
||||
// Base JS recommended rules
|
||||
// Base JS rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript recommended rules
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// Global ignores
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**', '**/coverage/**'],
|
||||
},
|
||||
|
||||
// Base config for TS/JS files
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
import: importPlugin,
|
||||
'prefer-arrow': preferArrowPlugin,
|
||||
'unused-imports': unusedImportsPlugin,
|
||||
},
|
||||
rules: {
|
||||
// General rules (aligned with main project)
|
||||
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
|
||||
'no-console': [
|
||||
'warn',
|
||||
{ allow: ['group', 'groupCollapsed', 'groupEnd'] },
|
||||
],
|
||||
'no-control-regex': 0,
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
|
||||
// Import rules
|
||||
'import/no-relative-packages': 'error',
|
||||
'import/no-useless-path-segments': 'error',
|
||||
'import/no-duplicates': ['error', { considerQueryString: true }],
|
||||
|
||||
// Prefer arrow functions
|
||||
'prefer-arrow/prefer-arrow-functions': [
|
||||
'error',
|
||||
{
|
||||
disallowPrototype: true,
|
||||
singleReturnOnly: false,
|
||||
classPropertiesAllowed: false,
|
||||
},
|
||||
],
|
||||
|
||||
// Unused imports
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
// Prettier (formatting as lint errors if you want)
|
||||
'prettier/prettier': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// TypeScript-specific configuration
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
},
|
||||
rules: {
|
||||
// Turn off base rule and use TS-aware versions
|
||||
'no-redeclare': 'off',
|
||||
'@typescript-eslint/no-redeclare': 'error',
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Test files (Jest)
|
||||
{
|
||||
files: ['**/*.spec.@(ts|tsx|js|jsx)', '**/*.test.@(ts|tsx|js|jsx)'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
jest: true,
|
||||
describe: true,
|
||||
it: true,
|
||||
expect: true,
|
||||
beforeEach: true,
|
||||
afterEach: true,
|
||||
beforeAll: true,
|
||||
afterAll: true,
|
||||
},
|
||||
},
|
||||
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
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
@@ -22,10 +21,6 @@
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
import { install } from '@/utils/install';
|
||||
import { tryGitInit } from '@/utils/try-git-init';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import kebabCase from 'lodash.kebabcase';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
type ExampleOptions,
|
||||
type ScaffoldingMode,
|
||||
} from '@/types/scaffolding-options';
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
import kebabCase from 'lodash.kebabcase';
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
import { tryGitInit } from '@/utils/try-git-init';
|
||||
import { install } from '@/utils/install';
|
||||
|
||||
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;
|
||||
@@ -227,15 +119,10 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
private logSuccess(appDirectory: string): void {
|
||||
const dirName = appDirectory.split('/').reverse()[0] ?? '';
|
||||
|
||||
console.log(chalk.green('✅ Application created!'));
|
||||
console.log('');
|
||||
console.log(chalk.blue('Next steps:'));
|
||||
console.log(chalk.gray(` cd ${dirName}`));
|
||||
console.log(chalk.gray(` corepack enable # if you don't use yarn@4`));
|
||||
console.log(chalk.gray(` yarn install # if you don't use yarn@4`));
|
||||
console.log(chalk.gray(' yarn auth:login # Authenticate with Twenty'));
|
||||
console.log(chalk.gray(' yarn app:dev # Start dev mode'));
|
||||
console.log(`cd ${appDirectory.split('/').reverse()[0] ?? ''}`);
|
||||
console.log('yarn auth');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,679 +0,0 @@
|
||||
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';
|
||||
|
||||
// Mock fs-extra's copy function to skip copying base template (not available during tests)
|
||||
jest.mock('fs-extra', () => {
|
||||
const actual = jest.requireActual('fs-extra');
|
||||
return {
|
||||
...actual,
|
||||
copy: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const APPLICATION_FILE_NAME = 'application-config.ts';
|
||||
const DEFAULT_ROLE_FILE_NAME = 'default-role.ts';
|
||||
|
||||
const ALL_EXAMPLES: ExampleOptions = {
|
||||
includeExampleObject: true,
|
||||
includeExampleField: true,
|
||||
includeExampleLogicFunction: true,
|
||||
includeExampleFrontComponent: true,
|
||||
includeExampleView: true,
|
||||
includeExampleNavigationMenuItem: true,
|
||||
includeExampleSkill: true,
|
||||
};
|
||||
|
||||
const NO_EXAMPLES: ExampleOptions = {
|
||||
includeExampleObject: false,
|
||||
includeExampleField: false,
|
||||
includeExampleSkill: false,
|
||||
includeExampleLogicFunction: false,
|
||||
includeExampleFrontComponent: false,
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
};
|
||||
|
||||
describe('copyBaseApplicationProject', () => {
|
||||
let testAppDirectory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique temp directory for each test
|
||||
testAppDirectory = join(
|
||||
tmpdir(),
|
||||
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
await fs.ensureDir(testAppDirectory);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temp directory after each test
|
||||
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
|
||||
await fs.remove(testAppDirectory);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create the correct folder structure with src/', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Verify src/ folder exists
|
||||
const srcAppPath = join(testAppDirectory, 'src');
|
||||
expect(await fs.pathExists(srcAppPath)).toBe(true);
|
||||
|
||||
// Verify application-config.ts exists in src/
|
||||
const appConfigPath = join(srcAppPath, APPLICATION_FILE_NAME);
|
||||
expect(await fs.pathExists(appConfigPath)).toBe(true);
|
||||
|
||||
// Verify default-role.ts exists in src/
|
||||
const roleConfigPath = join(srcAppPath, 'roles', DEFAULT_ROLE_FILE_NAME);
|
||||
expect(await fs.pathExists(roleConfigPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should create package.json with correct content', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const packageJsonPath = join(testAppDirectory, 'package.json');
|
||||
expect(await fs.pathExists(packageJsonPath)).toBe(true);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('should create .gitignore file', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const gitignorePath = join(testAppDirectory, '.gitignore');
|
||||
expect(await fs.pathExists(gitignorePath)).toBe(true);
|
||||
|
||||
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
||||
expect(gitignoreContent).toContain('/node_modules');
|
||||
expect(gitignoreContent).toContain(GENERATED_DIR);
|
||||
});
|
||||
|
||||
it('should create yarn.lock file', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const yarnLockPath = join(testAppDirectory, 'yarn.lock');
|
||||
expect(await fs.pathExists(yarnLockPath)).toBe(true);
|
||||
|
||||
const yarnLockContent = await fs.readFile(yarnLockPath, 'utf8');
|
||||
expect(yarnLockContent).toContain('yarn lockfile v1');
|
||||
});
|
||||
|
||||
it('should create application-config.ts with defineApplication and correct values', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
|
||||
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
|
||||
|
||||
// Verify it uses defineApplication
|
||||
expect(appConfigContent).toContain(
|
||||
"import { defineApplication } from 'twenty-sdk'",
|
||||
);
|
||||
expect(appConfigContent).toContain('export default defineApplication({');
|
||||
|
||||
// Verify it imports the role identifier
|
||||
expect(appConfigContent).toContain(
|
||||
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
|
||||
);
|
||||
|
||||
// Verify display name and description
|
||||
expect(appConfigContent).toContain("displayName: 'My Test App'");
|
||||
expect(appConfigContent).toContain("description: 'A test application'");
|
||||
|
||||
// Verify it has a universalIdentifier (UUID format)
|
||||
expect(appConfigContent).toMatch(
|
||||
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
|
||||
);
|
||||
|
||||
// Verify it references the role
|
||||
expect(appConfigContent).toContain(
|
||||
'defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create default-role.ts with defineRole and correct values', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const roleConfigPath = join(
|
||||
testAppDirectory,
|
||||
'src',
|
||||
'roles',
|
||||
DEFAULT_ROLE_FILE_NAME,
|
||||
);
|
||||
const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8');
|
||||
|
||||
// Verify it uses defineRole
|
||||
expect(roleConfigContent).toContain(
|
||||
"import { defineRole } from 'twenty-sdk'",
|
||||
);
|
||||
expect(roleConfigContent).toContain('export default defineRole({');
|
||||
|
||||
// Verify it exports the universal identifier constant
|
||||
expect(roleConfigContent).toContain(
|
||||
'export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
|
||||
);
|
||||
|
||||
// Verify role label includes app name
|
||||
expect(roleConfigContent).toContain(
|
||||
"label: 'My Test App default function role'",
|
||||
);
|
||||
|
||||
// Verify default permissions
|
||||
expect(roleConfigContent).toContain('canReadAllObjectRecords: true');
|
||||
expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true');
|
||||
expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true');
|
||||
expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false');
|
||||
|
||||
// Verify it has a universalIdentifier (UUID format)
|
||||
expect(roleConfigContent).toMatch(
|
||||
/universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call fs.copy to copy base application template', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Verify fs.copy was called with correct destination
|
||||
expect(fs.copy).toHaveBeenCalledTimes(1);
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('base-application'),
|
||||
testAppDirectory,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty description', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: '',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
|
||||
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
|
||||
|
||||
expect(appConfigContent).toContain("description: ''");
|
||||
});
|
||||
|
||||
it('should generate unique UUIDs for each application', async () => {
|
||||
// Create first app
|
||||
const firstAppDir = join(testAppDirectory, 'app1');
|
||||
await fs.ensureDir(firstAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-one',
|
||||
appDisplayName: 'App One',
|
||||
appDescription: 'First app',
|
||||
appDirectory: firstAppDir,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-two',
|
||||
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),
|
||||
'utf8',
|
||||
);
|
||||
const secondAppConfig = await fs.readFile(
|
||||
join(secondAppDir, 'src', APPLICATION_FILE_NAME),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Extract UUIDs using regex
|
||||
const uuidRegex =
|
||||
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
|
||||
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
|
||||
const secondUuid = secondAppConfig.match(uuidRegex)?.[1];
|
||||
|
||||
expect(firstUuid).toBeDefined();
|
||||
expect(secondUuid).toBeDefined();
|
||||
expect(firstUuid).not.toBe(secondUuid);
|
||||
});
|
||||
|
||||
it('should generate unique role UUIDs for each application', async () => {
|
||||
// Create first app
|
||||
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,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
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 firstRoleConfig = await fs.readFile(
|
||||
join(firstAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const secondRoleConfig = await fs.readFile(
|
||||
join(secondAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Extract UUIDs using regex
|
||||
const uuidRegex =
|
||||
/DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
|
||||
const firstUuid = firstRoleConfig.match(uuidRegex)?.[1];
|
||||
const secondUuid = secondRoleConfig.match(uuidRegex)?.[1];
|
||||
|
||||
expect(firstUuid).toBeDefined();
|
||||
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,17 @@
|
||||
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';
|
||||
|
||||
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 +19,15 @@ export const copyBaseApplicationProject = async ({
|
||||
|
||||
await createGitignore(appDirectory);
|
||||
|
||||
await createPublicAssetDirectory(appDirectory);
|
||||
|
||||
await createYarnLock(appDirectory);
|
||||
|
||||
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
|
||||
|
||||
await fs.ensureDir(sourceFolderPath);
|
||||
|
||||
await createDefaultRoleConfig({
|
||||
displayName: appDisplayName,
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'roles',
|
||||
fileName: 'default-role.ts',
|
||||
});
|
||||
|
||||
if (exampleOptions.includeExampleObject) {
|
||||
await createExampleObject({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'objects',
|
||||
fileName: 'example-object.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleField) {
|
||||
await createExampleField({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'fields',
|
||||
fileName: 'example-field.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleLogicFunction) {
|
||||
await createDefaultFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'hello-world.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleFrontComponent) {
|
||||
await createDefaultFrontComponent({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'front-components',
|
||||
fileName: 'hello-world.tsx',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleView) {
|
||||
await createExampleView({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'views',
|
||||
fileName: 'example-view.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleNavigationMenuItem) {
|
||||
await createExampleNavigationMenuItem({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'navigation-menu-items',
|
||||
fileName: 'example-navigation-menu-item.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleSkill) {
|
||||
await createExampleSkill({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'skills',
|
||||
fileName: 'example-skill.ts',
|
||||
});
|
||||
}
|
||||
|
||||
await createDefaultPostInstallFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'post-install.ts',
|
||||
});
|
||||
|
||||
await createApplicationConfig({
|
||||
displayName: appDisplayName,
|
||||
description: appDescription,
|
||||
appDirectory: sourceFolderPath,
|
||||
fileName: 'application-config.ts',
|
||||
appDirectory,
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
@@ -140,9 +53,6 @@ generated
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
.twenty/*
|
||||
!.twenty/output/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
@@ -166,330 +76,27 @@ yarn-error.log*
|
||||
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
|
||||
};
|
||||
|
||||
const createDefaultRoleConfig = 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 =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: '${displayName} default function role',
|
||||
description: '${displayName} default function role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createDefaultFrontComponent = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
export const HelloWorld = () => {
|
||||
return (
|
||||
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
||||
<h1>Hello, World!</h1>
|
||||
<p>This is your first front component.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'hello-world-front-component',
|
||||
description: 'A sample front component',
|
||||
component: HelloWorld,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createDefaultFunction = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
const handler = async (): Promise<{ message: string }> => {
|
||||
return { message: 'Hello, World!' };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'hello-world-logic-function',
|
||||
description: 'A simple logic function',
|
||||
timeoutSeconds: 5,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/hello-world-logic-function',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const 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: [
|
||||
{
|
||||
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT,
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
description: 'Name of the example item',
|
||||
icon: 'IconAbc',
|
||||
},
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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 { type ApplicationConfig } from 'twenty-sdk';
|
||||
|
||||
export default defineApplication({
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: '${v4()}',
|
||||
displayName: '${displayName}',
|
||||
description: '${description ?? ''}',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
`;
|
||||
|
||||
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,20 +117,21 @@ const createPackageJson = async ({
|
||||
},
|
||||
packageManager: 'yarn@4.9.2',
|
||||
scripts: {
|
||||
twenty: 'twenty',
|
||||
lint: 'eslint',
|
||||
'lint:fix': 'eslint --fix',
|
||||
'create-entity': 'twenty app add',
|
||||
dev: 'twenty app dev',
|
||||
generate: 'twenty app generate',
|
||||
sync: 'twenty app sync',
|
||||
logs: 'twenty app logs',
|
||||
uninstall: 'twenty app uninstall',
|
||||
help: 'twenty help',
|
||||
auth: 'twenty auth login',
|
||||
},
|
||||
dependencies: {
|
||||
'twenty-sdk': 'latest',
|
||||
'twenty-sdk': '0.2.0',
|
||||
},
|
||||
devDependencies: {
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
'@types/react': '^18.2.0',
|
||||
react: '^18.2.0',
|
||||
eslint: '^9.32.0',
|
||||
'typescript-eslint': '^8.50.0',
|
||||
typescript: '^5.9.3',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
@@ -9,18 +8,19 @@
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"noEmit": true,
|
||||
"types": ["jest", "node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react"
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.d.ts",
|
||||
"**/__mocks__/**/*",
|
||||
"vite.config.ts",
|
||||
"jest.config.mjs"
|
||||
]
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"**/__mocks__/**/*",
|
||||
"vite.config.ts",
|
||||
"jest.config.mjs",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx"
|
||||
]
|
||||
}
|
||||
@@ -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,12 @@ 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',
|
||||
],
|
||||
output: [
|
||||
{
|
||||
format: 'es',
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
generated
|
||||
|
||||
|
||||
+942
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
Vendored
Executable
+942
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
+942
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -230,7 +230,7 @@ npm run test -- --watch
|
||||
npx twenty-cli app dev
|
||||
|
||||
# Type checking
|
||||
npx tsgo --noEmit
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "tsc --noEmit --project {projectRoot}/tsconfig.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
|
||||
Binary file not shown.
Vendored
Executable
+942
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
Updates Last interaction and Interaction status fields based on last email date
|
||||
|
||||
## Requirements
|
||||
- an `apiKey` - go to Settings > API & Webhooks to generate one
|
||||
- twenty-cli `npm install -g twenty-cli`
|
||||
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
|
||||
|
||||
## Setup
|
||||
1. Add and synchronize app
|
||||
```bash
|
||||
cd packages/twenty-apps/community/last-email-interaction
|
||||
yarn auth
|
||||
yarn sync
|
||||
twenty auth login
|
||||
cd last_email_interaction
|
||||
twenty app sync
|
||||
```
|
||||
2. Go to Settings > Integrations > Last email interaction > Settings and add required variables
|
||||
|
||||
## Flow
|
||||
- Checks if fields are created, if not, creates them on fly
|
||||
- Extracts the datetime of message and calculates the last interaction status
|
||||
- Fetches all users and companies connected to them and updates their Last interaction and Interaction status fields
|
||||
|
||||
## Todo:
|
||||
- update app with generated Twenty object once extending objects is possible
|
||||
- Fetches all users and companies connected to them and updates their Last interaction and Interaction status fields
|
||||
@@ -1,11 +1,10 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk';
|
||||
import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: '718ed9ab-53fc-49c8-8deb-0cff78ecf0d2',
|
||||
displayName: 'Last email interaction',
|
||||
description:
|
||||
'Updates Last interaction and Interaction status fields based on last received email',
|
||||
icon: "IconMailFast",
|
||||
applicationVariables: {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: 'aae3f523-4c1f-4805-b3ee-afeb676c381e',
|
||||
|
||||
@@ -10,18 +10,9 @@
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2",
|
||||
"twenty-sdk": "0.2.4"
|
||||
"twenty-sdk": "0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
},
|
||||
"scripts": {
|
||||
"auth": "twenty auth login",
|
||||
"dev": "twenty app dev",
|
||||
"sync": "twenty app sync",
|
||||
"uninstall": "twenty app uninstall",
|
||||
"logs": "twenty app logs",
|
||||
"create-entity": "twenty app add",
|
||||
"help": "twenty --help"
|
||||
}
|
||||
}
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
import axios from 'axios';
|
||||
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
|
||||
|
||||
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '';
|
||||
const TWENTY_URL =
|
||||
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
|
||||
? `${process.env.TWENTY_API_URL}/rest`
|
||||
: 'https://api.twenty.com/rest';
|
||||
|
||||
const create_last_interaction = (id: string) => {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: `${TWENTY_URL}/metadata/fields`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
type: 'DATE_TIME',
|
||||
objectMetadataId: `${id}`,
|
||||
name: 'lastInteraction',
|
||||
label: 'Last interaction',
|
||||
description: 'Date when the last interaction happened',
|
||||
icon: 'IconCalendarClock',
|
||||
defaultValue: null,
|
||||
isNullable: true,
|
||||
settings: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const create_interaction_status = (id: string) => {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: `${TWENTY_URL}/metadata/fields`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
type: 'SELECT',
|
||||
objectMetadataId: `${id}`,
|
||||
name: 'interactionStatus',
|
||||
label: 'Interaction status',
|
||||
description: 'Indicates the health of relation',
|
||||
icon: 'IconProgress',
|
||||
defaultValue: null,
|
||||
isNullable: true,
|
||||
settings: {},
|
||||
options: [
|
||||
{
|
||||
color: 'green',
|
||||
label: 'Recent',
|
||||
value: 'RECENT',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
color: 'yellow',
|
||||
label: 'Active',
|
||||
value: 'ACTIVE',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
color: 'sky',
|
||||
label: 'Cooling',
|
||||
value: 'COOLING',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
label: 'Dormant',
|
||||
value: 'DORMANT',
|
||||
position: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const calculateStatus = (date: string) => {
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const now = Date.now();
|
||||
const messageDate = Date.parse(date);
|
||||
const deltaTime = now - messageDate;
|
||||
return deltaTime < 7 * day
|
||||
? 'RECENT'
|
||||
: deltaTime < 30 * day
|
||||
? 'ACTIVE'
|
||||
: deltaTime < 90 * day
|
||||
? 'COOLING'
|
||||
: 'DORMANT';
|
||||
};
|
||||
|
||||
const interactionData = (date: string, status: string) => {
|
||||
return {
|
||||
lastInteraction: date,
|
||||
interactionStatus: status,
|
||||
};
|
||||
};
|
||||
|
||||
const updateInteractionStatus = async (objectName: string, id: string, messageDate: string, status: string) => {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
url: `${TWENTY_URL}/${objectName}/${id}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
lastInteraction: messageDate,
|
||||
interactionStatus: status
|
||||
}
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
if (response.status === 200) {
|
||||
console.log('Successfully updated company last interaction field');
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRelatedCompanyId = async (id: string) => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/people/${id}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const req = await axios.request(options);
|
||||
if (req.status === 200 && req.data.person.companyId !== null && req.data.person.companyId !== undefined) {
|
||||
return req.data.person.companyId;
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const main = async (params: {
|
||||
properties: Record<string, any>;
|
||||
recordId: string;
|
||||
userId: string;
|
||||
}): Promise<object | undefined> => {
|
||||
if (TWENTY_API_KEY === '') {
|
||||
console.log("Function exited as API key or URL hasn't been set properly");
|
||||
return {};
|
||||
}
|
||||
const { properties, recordId } = params;
|
||||
// Check if fields are created
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/metadata/objects`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
const objects = response.data.data.objects;
|
||||
const company_object = objects.find(
|
||||
(object: any) => object.nameSingular === 'company',
|
||||
);
|
||||
const company_last_interaction = company_object.fields.find(
|
||||
(field: any) => field.name === 'lastInteraction',
|
||||
);
|
||||
const company_interaction_status = company_object.fields.find(
|
||||
(field: any) => field.name === 'interactionStatus',
|
||||
);
|
||||
const person_object = objects.find(
|
||||
(object: any) => object.nameSingular === 'person',
|
||||
);
|
||||
const person_last_interaction = person_object.fields.find(
|
||||
(field: any) => field.name === 'lastInteraction',
|
||||
);
|
||||
const person_interaction_status = person_object.fields.find(
|
||||
(field: any) => field.name === 'interactionStatus',
|
||||
);
|
||||
// If not, create them
|
||||
if (company_last_interaction === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_last_interaction(company_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created company last interaction field');
|
||||
}
|
||||
}
|
||||
if (company_interaction_status === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_interaction_status(company_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created company interaction status field');
|
||||
}
|
||||
}
|
||||
if (person_last_interaction === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_last_interaction(person_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created person last interaction field');
|
||||
}
|
||||
}
|
||||
if (person_interaction_status === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_interaction_status(person_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created person interaction status field');
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the timestamp of message
|
||||
const messageDate = properties.after.receivedAt;
|
||||
const interactionStatus = calculateStatus(messageDate);
|
||||
|
||||
// Get the details of person and related company
|
||||
const messageOptions = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/messages/${recordId}?depth=1`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
const messageDetails = await axios.request(messageOptions);
|
||||
const peopleIds: string[] = [];
|
||||
for (const participant of messageDetails.data.messages
|
||||
.messageParticipants) {
|
||||
peopleIds.push(participant.personId);
|
||||
}
|
||||
|
||||
const companiesIds = [];
|
||||
for (const id of peopleIds) {
|
||||
companiesIds.push(await fetchRelatedCompanyId(id));
|
||||
}
|
||||
// Update the field value depending on the timestamp
|
||||
for (const id of peopleIds) {
|
||||
await updateInteractionStatus("people", id, messageDate, interactionStatus);
|
||||
}
|
||||
|
||||
for (const id of companiesIds) {
|
||||
await updateInteractionStatus("companies", id, messageDate, interactionStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(error.message);
|
||||
return {};
|
||||
}
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const config: ServerlessFunctionConfig = {
|
||||
universalIdentifier: '683966a0-b60a-424e-86b1-7448c9191bde',
|
||||
name: 'test',
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'f4f1e127-87f0-4dcf-99fe-8061adf5cbe6',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'message.created',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '4c17878f-b6b3-4d0a-8de6-967b1cb55002',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'message.updated',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,270 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { type FunctionConfig } from 'twenty-sdk';
|
||||
|
||||
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '';
|
||||
const TWENTY_URL =
|
||||
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
|
||||
? `${process.env.TWENTY_API_URL}/rest`
|
||||
: 'https://api.twenty.com/rest';
|
||||
|
||||
const create_last_interaction = (id: string) => {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: `${TWENTY_URL}/metadata/fields`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
type: 'DATE_TIME',
|
||||
objectMetadataId: `${id}`,
|
||||
name: 'lastInteraction',
|
||||
label: 'Last interaction',
|
||||
description: 'Date when the last interaction happened',
|
||||
icon: 'IconCalendarClock',
|
||||
defaultValue: null,
|
||||
isNullable: true,
|
||||
settings: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const create_interaction_status = (id: string) => {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: `${TWENTY_URL}/metadata/fields`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
type: 'SELECT',
|
||||
objectMetadataId: `${id}`,
|
||||
name: 'interactionStatus',
|
||||
label: 'Interaction status',
|
||||
description: 'Indicates the health of relation',
|
||||
icon: 'IconProgress',
|
||||
defaultValue: null,
|
||||
isNullable: true,
|
||||
settings: {},
|
||||
options: [
|
||||
{
|
||||
color: 'green',
|
||||
label: 'Recent',
|
||||
value: 'RECENT',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
color: 'yellow',
|
||||
label: 'Active',
|
||||
value: 'ACTIVE',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
color: 'sky',
|
||||
label: 'Cooling',
|
||||
value: 'COOLING',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
label: 'Dormant',
|
||||
value: 'DORMANT',
|
||||
position: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const calculateStatus = (date: string) => {
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const now = Date.now();
|
||||
const messageDate = Date.parse(date);
|
||||
const deltaTime = now - messageDate;
|
||||
return deltaTime < 7 * day
|
||||
? 'RECENT'
|
||||
: deltaTime < 30 * day
|
||||
? 'ACTIVE'
|
||||
: deltaTime < 90 * day
|
||||
? 'COOLING'
|
||||
: 'DORMANT';
|
||||
};
|
||||
|
||||
const updateInteractionStatus = async (objectName: string, id: string, messageDate: string, status: string) => {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
url: `${TWENTY_URL}/${objectName}/${id}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
data: {
|
||||
lastInteraction: messageDate,
|
||||
interactionStatus: status
|
||||
}
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
if (response.status === 200) {
|
||||
console.log('Successfully updated company last interaction field');
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRelatedCompanyId = async (id: string) => {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/people/${id}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const req = await axios.request(options);
|
||||
if (req.status === 200 && req.data.person.companyId !== null && req.data.person.companyId !== undefined) {
|
||||
return req.data.person.companyId;
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const main = async (params: {
|
||||
properties: Record<string, any>;
|
||||
recordId: string;
|
||||
userId: string;
|
||||
}): Promise<object | undefined> => {
|
||||
if (TWENTY_API_KEY === '') {
|
||||
console.log("Function exited as API key or URL hasn't been set properly");
|
||||
return {};
|
||||
}
|
||||
const { properties, recordId } = params;
|
||||
// Check if fields are created
|
||||
const options = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/metadata/objects`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
const objects = response.data.data.objects;
|
||||
const company_object = objects.find(
|
||||
(object: any) => object.nameSingular === 'company',
|
||||
);
|
||||
const company_last_interaction = company_object.fields.find(
|
||||
(field: any) => field.name === 'lastInteraction',
|
||||
);
|
||||
const company_interaction_status = company_object.fields.find(
|
||||
(field: any) => field.name === 'interactionStatus',
|
||||
);
|
||||
const person_object = objects.find(
|
||||
(object: any) => object.nameSingular === 'person',
|
||||
);
|
||||
const person_last_interaction = person_object.fields.find(
|
||||
(field: any) => field.name === 'lastInteraction',
|
||||
);
|
||||
const person_interaction_status = person_object.fields.find(
|
||||
(field: any) => field.name === 'interactionStatus',
|
||||
);
|
||||
// If not, create them
|
||||
if (company_last_interaction === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_last_interaction(company_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created company last interaction field');
|
||||
}
|
||||
}
|
||||
if (company_interaction_status === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_interaction_status(company_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created company interaction status field');
|
||||
}
|
||||
}
|
||||
if (person_last_interaction === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_last_interaction(person_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created person last interaction field');
|
||||
}
|
||||
}
|
||||
if (person_interaction_status === undefined) {
|
||||
const response2 = await axios.request(
|
||||
create_interaction_status(person_object.id),
|
||||
);
|
||||
if (response2.status === 201) {
|
||||
console.log('Successfully created person interaction status field');
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the timestamp of message
|
||||
const messageDate = properties.after.receivedAt;
|
||||
const interactionStatus = calculateStatus(messageDate);
|
||||
|
||||
// Get the details of person and related company
|
||||
const messageOptions = {
|
||||
method: 'GET',
|
||||
url: `${TWENTY_URL}/messages/${recordId}?depth=1`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TWENTY_API_KEY}`,
|
||||
},
|
||||
};
|
||||
const messageDetails = await axios.request(messageOptions);
|
||||
const peopleIds: string[] = [];
|
||||
for (const participant of messageDetails.data.messages
|
||||
.messageParticipants) {
|
||||
peopleIds.push(participant.personId);
|
||||
}
|
||||
|
||||
const companiesIds = [];
|
||||
for (const id of peopleIds) {
|
||||
companiesIds.push(await fetchRelatedCompanyId(id));
|
||||
}
|
||||
// Update the field value depending on the timestamp
|
||||
for (const id of peopleIds) {
|
||||
await updateInteractionStatus("people", id, messageDate, interactionStatus);
|
||||
}
|
||||
|
||||
for (const id of companiesIds) {
|
||||
await updateInteractionStatus("companies", id, messageDate, interactionStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(error.message);
|
||||
return {};
|
||||
}
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const config: FunctionConfig = {
|
||||
universalIdentifier: '683966a0-b60a-424e-86b1-7448c9191bde',
|
||||
name: 'test',
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'f4f1e127-87f0-4dcf-99fe-8061adf5cbe6',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'message.created',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '4c17878f-b6b3-4d0a-8de6-967b1cb55002',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'message.updated',
|
||||
},
|
||||
],
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
Executable
+942
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user