Compare commits

...

13 Commits

Author SHA1 Message Date
Félix Malfait 7d68868df5 feat: fix junction toggle persistence and add type-safe documentation paths (#17421)
## Summary

- **Fix junction relation toggle not being saved**: The form schema
wasn't tracking the `settings` field, so changes to
`junctionTargetFieldId` weren't marked as dirty
- **Add type-safe documentation paths**: Generate TypeScript constants
from `base-structure.json` to prevent broken documentation links
- **Create many-to-many relations documentation**: Step-by-step guide
for building many-to-many relations using junction objects
- **Update `getDocumentationUrl`**: Now uses shared constants from
`twenty-shared` for base URL, default path, and supported languages

## Key Changes

### Junction Toggle Fix
- Added `settings` field to the form schema in
`SettingsDataModelFieldRelationForm.tsx`
- Fixed the toggle to properly merge settings when updating
`junctionTargetFieldId`

### Type-Safe Documentation Paths
- New constants in `twenty-shared/constants`:
- `DOCUMENTATION_PATHS` - All 161 documentation paths as typed constants
  - `DOCUMENTATION_SUPPORTED_LANGUAGES` - 14 supported languages
  - `DOCUMENTATION_BASE_URL` / `DOCUMENTATION_DEFAULT_PATH`
- Generator script: `yarn docs:generate-paths`
- CI integration: Added to `docs-i18n-pull.yaml` workflow

### Documentation
- New article:
`/user-guide/data-model/how-tos/create-many-to-many-relations`
- Updated `/user-guide/data-model/capabilities/relation-fields.mdx` with
Lab warning and link

## Test plan
- [ ] Verify junction toggle saves correctly when enabled/disabled
- [ ] Verify documentation link opens correct localized page
- [ ] Verify `yarn docs:generate-paths` regenerates paths correctly
2026-02-02 17:36:08 +01:00
Etienne 4dfb4a8fcb Files - Delete command (#17576) 2026-01-30 15:03:31 +01:00
Thomas Trompette c5be4d2d60 [Bug] Fix broken variables for database event (#17526)
Fix https://github.com/twentyhq/twenty/issues/17524

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

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

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

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

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

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

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

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

Related
https://github.com/twentyhq/twenty/issues/17413#issuecomment-3799872079
2026-01-26 16:50:19 +01:00
Paul Rastoin cb6957fa51 Fix upgrade command order backfillStandardPageLayoutsCommand (#17430)
# Introduction
`backfillStandardPageLayoutsCommand` expects field and object metadata
to have been identified in prior to be run
https://github.com/twentyhq/twenty/issues/17413#issuecomment-3796933563
2026-01-25 21:53:29 +01:00
Paul Rastoin 9633740132 [Debug log level] Print validation build result failure (#17423)
# Introduction
As per title, in order to ease debug
Motivation
https://github.com/twentyhq/twenty/issues/17413#issuecomment-3796168946
2026-01-25 12:38:16 +01:00
35 changed files with 1362 additions and 163 deletions
+4 -1
View File
@@ -107,10 +107,13 @@ 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
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json packages/twenty-shared/src/constants/DocumentationPaths.ts
if git diff --staged --quiet --exit-code; then
echo "No navigation/doc changes to commit."
exit 0
+1
View File
@@ -210,6 +210,7 @@
"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": {
+14
View File
@@ -80,6 +80,7 @@
"user-guide/data-model/how-tos/create-custom-objects",
"user-guide/data-model/how-tos/create-custom-fields",
"user-guide/data-model/how-tos/create-relation-fields",
"user-guide/data-model/how-tos/create-many-to-many-relations",
"user-guide/data-model/how-tos/customize-your-data-model",
"user-guide/data-model/how-tos/data-model-faq"
]
@@ -524,6 +525,7 @@
"l/fr/user-guide/data-model/how-tos/create-custom-objects",
"l/fr/user-guide/data-model/how-tos/create-custom-fields",
"l/fr/user-guide/data-model/how-tos/create-relation-fields",
"l/fr/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/fr/user-guide/data-model/how-tos/customize-your-data-model",
"l/fr/user-guide/data-model/how-tos/data-model-faq"
]
@@ -968,6 +970,7 @@
"l/ar/user-guide/data-model/how-tos/create-custom-objects",
"l/ar/user-guide/data-model/how-tos/create-custom-fields",
"l/ar/user-guide/data-model/how-tos/create-relation-fields",
"l/ar/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/ar/user-guide/data-model/how-tos/customize-your-data-model",
"l/ar/user-guide/data-model/how-tos/data-model-faq"
]
@@ -1412,6 +1415,7 @@
"l/cs/user-guide/data-model/how-tos/create-custom-objects",
"l/cs/user-guide/data-model/how-tos/create-custom-fields",
"l/cs/user-guide/data-model/how-tos/create-relation-fields",
"l/cs/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/cs/user-guide/data-model/how-tos/customize-your-data-model",
"l/cs/user-guide/data-model/how-tos/data-model-faq"
]
@@ -1856,6 +1860,7 @@
"l/de/user-guide/data-model/how-tos/create-custom-objects",
"l/de/user-guide/data-model/how-tos/create-custom-fields",
"l/de/user-guide/data-model/how-tos/create-relation-fields",
"l/de/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/de/user-guide/data-model/how-tos/customize-your-data-model",
"l/de/user-guide/data-model/how-tos/data-model-faq"
]
@@ -2300,6 +2305,7 @@
"l/es/user-guide/data-model/how-tos/create-custom-objects",
"l/es/user-guide/data-model/how-tos/create-custom-fields",
"l/es/user-guide/data-model/how-tos/create-relation-fields",
"l/es/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/es/user-guide/data-model/how-tos/customize-your-data-model",
"l/es/user-guide/data-model/how-tos/data-model-faq"
]
@@ -2744,6 +2750,7 @@
"l/it/user-guide/data-model/how-tos/create-custom-objects",
"l/it/user-guide/data-model/how-tos/create-custom-fields",
"l/it/user-guide/data-model/how-tos/create-relation-fields",
"l/it/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/it/user-guide/data-model/how-tos/customize-your-data-model",
"l/it/user-guide/data-model/how-tos/data-model-faq"
]
@@ -3188,6 +3195,7 @@
"l/ja/user-guide/data-model/how-tos/create-custom-objects",
"l/ja/user-guide/data-model/how-tos/create-custom-fields",
"l/ja/user-guide/data-model/how-tos/create-relation-fields",
"l/ja/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/ja/user-guide/data-model/how-tos/customize-your-data-model",
"l/ja/user-guide/data-model/how-tos/data-model-faq"
]
@@ -3632,6 +3640,7 @@
"l/ko/user-guide/data-model/how-tos/create-custom-objects",
"l/ko/user-guide/data-model/how-tos/create-custom-fields",
"l/ko/user-guide/data-model/how-tos/create-relation-fields",
"l/ko/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/ko/user-guide/data-model/how-tos/customize-your-data-model",
"l/ko/user-guide/data-model/how-tos/data-model-faq"
]
@@ -4076,6 +4085,7 @@
"l/pt/user-guide/data-model/how-tos/create-custom-objects",
"l/pt/user-guide/data-model/how-tos/create-custom-fields",
"l/pt/user-guide/data-model/how-tos/create-relation-fields",
"l/pt/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/pt/user-guide/data-model/how-tos/customize-your-data-model",
"l/pt/user-guide/data-model/how-tos/data-model-faq"
]
@@ -4520,6 +4530,7 @@
"l/ro/user-guide/data-model/how-tos/create-custom-objects",
"l/ro/user-guide/data-model/how-tos/create-custom-fields",
"l/ro/user-guide/data-model/how-tos/create-relation-fields",
"l/ro/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/ro/user-guide/data-model/how-tos/customize-your-data-model",
"l/ro/user-guide/data-model/how-tos/data-model-faq"
]
@@ -4964,6 +4975,7 @@
"l/ru/user-guide/data-model/how-tos/create-custom-objects",
"l/ru/user-guide/data-model/how-tos/create-custom-fields",
"l/ru/user-guide/data-model/how-tos/create-relation-fields",
"l/ru/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/ru/user-guide/data-model/how-tos/customize-your-data-model",
"l/ru/user-guide/data-model/how-tos/data-model-faq"
]
@@ -5408,6 +5420,7 @@
"l/tr/user-guide/data-model/how-tos/create-custom-objects",
"l/tr/user-guide/data-model/how-tos/create-custom-fields",
"l/tr/user-guide/data-model/how-tos/create-relation-fields",
"l/tr/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/tr/user-guide/data-model/how-tos/customize-your-data-model",
"l/tr/user-guide/data-model/how-tos/data-model-faq"
]
@@ -5852,6 +5865,7 @@
"l/zh/user-guide/data-model/how-tos/create-custom-objects",
"l/zh/user-guide/data-model/how-tos/create-custom-fields",
"l/zh/user-guide/data-model/how-tos/create-relation-fields",
"l/zh/user-guide/data-model/how-tos/create-many-to-many-relations",
"l/zh/user-guide/data-model/how-tos/customize-your-data-model",
"l/zh/user-guide/data-model/how-tos/data-model-faq"
]
@@ -52,6 +52,7 @@
"user-guide/data-model/how-tos/create-custom-objects",
"user-guide/data-model/how-tos/create-custom-fields",
"user-guide/data-model/how-tos/create-relation-fields",
"user-guide/data-model/how-tos/create-many-to-many-relations",
"user-guide/data-model/how-tos/customize-your-data-model",
"user-guide/data-model/how-tos/data-model-faq"
]
@@ -1,21 +1,7 @@
export const DEFAULT_LANGUAGE = 'en' as const;
export const SUPPORTED_LANGUAGES = [
DEFAULT_LANGUAGE,
'fr',
'ar',
'cs',
'de',
'es',
'it',
'ja',
'ko',
'pt',
'ro',
'ru',
'tr',
'zh',
] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
// Re-export from twenty-shared (source of truth)
// Using relative path because these scripts run via tsx from workspace root
export { DOCUMENTATION_DEFAULT_LANGUAGE as DEFAULT_LANGUAGE } from '../../twenty-shared/src/constants/DocumentationDefaultLanguage';
export {
DOCUMENTATION_SUPPORTED_LANGUAGES as SUPPORTED_LANGUAGES,
type DocumentationSupportedLanguage as SupportedLanguage,
} from '../../twenty-shared/src/constants/DocumentationSupportedLanguages';
+3
View File
@@ -13,6 +13,9 @@
"dependencies": {
"mintlify": "latest"
},
"devDependencies": {
"twenty-shared": "workspace:*"
},
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
@@ -0,0 +1,101 @@
import fs from 'fs';
import path from 'path';
type BasePage = string | BaseGroup;
type BaseGroup = {
key: string;
label: string;
icon?: string;
pages: BasePage[];
};
type BaseTab = {
key: string;
label: string;
groups: BaseGroup[];
};
type BaseStructure = {
tabs: BaseTab[];
};
const baseStructurePath = path.resolve(
__dirname,
'../navigation/base-structure.json',
);
const outputDir = path.resolve(__dirname, '../../twenty-shared/src/constants');
const baseStructure: BaseStructure = JSON.parse(
fs.readFileSync(baseStructurePath, 'utf8'),
);
const extractPaths = (pages: BasePage[]): string[] => {
const paths: string[] = [];
for (const page of pages) {
if (typeof page === 'string') {
paths.push(page);
} else {
paths.push(...extractPaths(page.pages));
}
}
return paths;
};
const pathToConstantName = (docPath: string): string => {
return docPath.replace(/^\//, '').replace(/[/-]/g, '_').toUpperCase();
};
const allPaths: string[] = [];
for (const tab of baseStructure.tabs) {
for (const group of tab.groups) {
allPaths.push(...extractPaths(group.pages));
}
}
const sortedPaths = [...allPaths].sort();
const AUTO_GENERATED_HEADER = `/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file
* | | \\ V V / __/ | | | |_| |_| | DO NOT EDIT - changes will be overwritten
* |_| \\_/\\_/ \\___|_| |_|\\__|\\__, | Generated by: yarn docs:generate-paths
* |___/
*
* This file is generated from packages/twenty-docs/navigation/base-structure.json
* To add new documentation pages, add them to base-structure.json and run:
* yarn docs:generate-paths
*/
`;
// Generate DocumentationPaths.ts
const pathEntries = sortedPaths
.map((docPath) => {
const constName = pathToConstantName(docPath);
const value = `'/${docPath}'`;
// Check if line would be too long (80 char limit)
if (` ${constName}: ${value},`.length > 80) {
return ` ${constName}:\n ${value},`;
}
return ` ${constName}: ${value},`;
})
.join('\n');
const pathsContent = `${AUTO_GENERATED_HEADER}export const DOCUMENTATION_PATHS = {
${pathEntries}
} as const;
export type DocumentationPath =
(typeof DOCUMENTATION_PATHS)[keyof typeof DOCUMENTATION_PATHS];
`;
fs.writeFileSync(path.join(outputDir, 'DocumentationPaths.ts'), pathsContent);
console.log(`Generated documentation constants to ${outputDir}`);
console.log(`Total paths: ${sortedPaths.length}`);
@@ -37,12 +37,16 @@ Many records in Object A can be linked to many records in Object B.
**Example:** Many People can be linked to many Projects, and vice versa.
<Warning>
**Many-to-Many is not yet supported.**
Many-to-many relations use a **junction object** pattern: an intermediate object that connects both sides. With the junction relation feature, Twenty displays the final linked records directly, hiding the intermediate object from the UI.
This relation type is planned for H1 2026. As a workaround, create an intermediate "junction" object (e.g., "Project Assignments") that has Many-to-One relations to both objects.
<img src="/images/user-guide/fields/junction-relation-diagram.png" style={{width:'100%'}}/>
<Warning>
**Lab Feature**: Junction relations must be enabled at **Settings → Updates → Lab** before use.
</Warning>
See [How to Create Many-to-Many Relations](/user-guide/data-model/how-tos/create-many-to-many-relations) for a complete step-by-step guide.
## Creating a Relation Field
1. Go to **Settings → Data Model**
@@ -0,0 +1,175 @@
---
title: Create Many-to-Many Relations
description: Connect records where many items on both sides can be linked together using junction objects.
---
Many-to-many relations let you connect multiple records on both sides. For example: many People can work on many Projects, and each Project can have many People.
<Warning>
**Lab Feature**: Junction relations are currently in the Lab. Enable them at **Settings → Updates → Lab** before following this guide.
</Warning>
<Note>
This feature also requires **Advanced mode** to be enabled (toggle at the bottom right of Settings).
</Note>
## When to Use Many-to-Many
Use many-to-many when both sides of a relationship can have multiple connections:
| Relationship | Example |
|-------------|---------|
| People ↔ Projects | A person works on multiple projects; a project has multiple team members |
| Companies ↔ Tags | A company can have multiple tags; a tag can apply to multiple companies |
| Products ↔ Orders | A product can be in multiple orders; an order contains multiple products |
## How It Works
Twenty uses a **junction object** pattern for many-to-many relations. A junction object sits between two objects and holds the connections:
```
People ←→ Project Assignments ←→ Projects
```
The **Project Assignments** object (junction) has:
- A relation to People (many-to-one)
- A relation to Projects (many-to-one)
When you enable the junction relation toggle, Twenty displays linked records directly instead of showing the intermediate junction records.
## Prerequisites
1. **Enable Junction Relations in Lab**: Go to **Settings → Updates → Lab** and enable **Junction Relations**
2. **Enable Advanced mode**: Toggle on **Advanced mode** at the bottom right of the Settings sidebar
3. Plan your data model:
- Which two objects are you connecting?
- What should the junction object be called?
## Step 1: Create the Junction Object
First, create the intermediate object that will hold the connections.
1. Go to **Settings → Data Model**
2. Click **+ New object**
3. Name it descriptively (e.g., "Project Assignment", "Team Member", "Product Order")
4. Click **Save**
<Tip>
**Naming convention**: Use a name that describes the relationship, like "Project Assignment" or "Team Membership". This makes the data model easier to understand.
</Tip>
## Step 2: Create Relations from the Junction Object
Add relation fields from the junction object to both objects you want to connect.
### First Relation (Junction → Object A)
1. Select your junction object in **Settings → Data Model**
2. Click **+ Add Field**
3. Choose **Relation** as the field type
4. Select the first object (e.g., "People")
5. Set the relation type to **Many-to-One** (many assignments can link to one person)
6. Name the fields:
- Field on junction: e.g., "Person"
- Field on People: e.g., "Project Assignments"
7. Click **Save**
### Second Relation (Junction → Object B)
1. Still on the junction object, click **+ Add Field**
2. Choose **Relation** as the field type
3. Select the second object (e.g., "Projects")
4. Set the relation type to **Many-to-One**
5. Name the fields:
- Field on junction: e.g., "Project"
- Field on Projects: e.g., "Team Members"
6. Click **Save**
## Step 3: Configure the Junction Relation Display
Now configure the source objects to display linked records directly, skipping the intermediate junction object.
1. Go to **Settings → Data Model**
2. Select the first object (e.g., "People")
3. Find the relation field pointing to the junction object (e.g., "Project Assignments")
4. Click to edit the field
5. Enable **"This is a relation to a Junction Object"**
6. Select the **Target relation** (e.g., "Project" — the field on the junction that points to the other side)
7. Click **Save**
{/* TODO: Add image
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
*/}
Repeat for the other object:
1. Select "Projects" in Data Model
2. Edit the "Team Members" relation field
3. Enable the junction toggle
4. Select "Person" as the target relation
5. Save
## Result
After configuration:
- On a **Person** record, the "Project Assignments" field displays **Projects** directly (not assignment records)
- On a **Project** record, the "Team Members" field displays **People** directly
The junction object still exists and stores the connections, but the UI presents a cleaner many-to-many view.
## Example: People ↔ Projects
Here's a complete walkthrough:
### Create the Junction Object
- Name: **Project Assignment**
- Description: "Links people to projects they work on"
### Add Relations
1. **Project Assignment → People**
- Type: Many-to-One
- Field on Assignment: "Person"
- Field on People: "Project Assignments"
2. **Project Assignment → Projects**
- Type: Many-to-One
- Field on Assignment: "Project"
- Field on Projects: "Team Members"
### Configure Junction Display
1. On **People** object:
- Edit "Project Assignments" field
- Enable junction toggle
- Target: "Project"
2. On **Projects** object:
- Edit "Team Members" field
- Enable junction toggle
- Target: "Person"
### Use It
- Open a Person record → See their Projects directly
- Open a Project record → See team members directly
- Create new connections from either side
## Adding Extra Data to Connections
Since the junction object is a real object, you can add custom fields to store information about the relationship:
- **Role**: "Developer", "Designer", "Manager"
- **Start Date**: When they joined the project
- **Hours Allocated**: Weekly hours on this project
To access this data, navigate to the junction object directly or query it via the API.
## Limitations
- **CSV Import/Export**: Importing many-to-many relations directly is not supported. Import records to the junction object instead.
- **Filters**: Filtering by many-to-many relations may have limited options.
## Related
- [Relation Fields](/user-guide/data-model/capabilities/relation-fields) — relation types explained
- [Create Custom Objects](/user-guide/data-model/how-tos/create-custom-objects) — how to create objects
- [Create Relation Fields](/user-guide/data-model/how-tos/create-relation-fields) — basic relation setup
@@ -32,4 +32,10 @@ export const StyledSettingsOptionCardTitle = styled.div`
export const StyledSettingsOptionCardDescription = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
a {
position: relative;
z-index: 1;
pointer-events: auto;
}
`;
@@ -41,7 +41,7 @@ const StyledSettingsOptionCardToggleCover = styled.span`
type SettingsOptionCardContentToggleProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
description?: React.ReactNode;
divider?: boolean;
disabled?: boolean;
advancedMode?: boolean;
@@ -55,6 +55,12 @@ export const settingsDataModelFieldMorphRelationFormSchema = z.object({
),
targetFieldLabel: z.string().min(1),
iconOnDestination: z.string().min(1),
settings: z
.object({
junctionTargetFieldId: z.string().optional(),
})
.catchall(z.unknown())
.optional(),
});
export type SettingsDataModelFieldMorphRelationFormValues = z.infer<
@@ -1,16 +1,19 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { useFormContext } from 'react-hook-form';
import { useRecoilValue } from 'recoil';
import { DOCUMENTATION_PATHS } from 'twenty-shared/constants';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconLink } from 'twenty-ui/display';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { Select } from '@/ui/input/components/Select';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconLink } from 'twenty-ui/display';
import { RelationType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
@@ -26,6 +29,12 @@ export const SettingsDataModelFieldRelationJunctionForm = ({
useFormContext<SettingsDataModelFieldEditFormValues>();
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const documentationUrl = getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
path: DOCUMENTATION_PATHS.USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_MANY_TO_MANY_RELATIONS,
});
const { objectMetadataItem: sourceObjectMetadataItem } =
useObjectMetadataItem({ objectNameSingular });
@@ -34,7 +43,8 @@ export const SettingsDataModelFieldRelationJunctionForm = ({
const relationType = watch('relationType') ?? RelationType.ONE_TO_MANY;
const targetObjectIds = watch('morphRelationObjectMetadataIds') ?? [];
const junctionTargetFieldId = watch('settings.junctionTargetFieldId');
const currentSettings = watch('settings');
const junctionTargetFieldId = currentSettings?.junctionTargetFieldId;
// Only applies to ONE_TO_MANY with single target
if (
@@ -112,23 +122,40 @@ export const SettingsDataModelFieldRelationJunctionForm = ({
const handleJunctionToggle = (checked: boolean) => {
if (checked && junctionFieldOptions.length > 0) {
setValue(
'settings.junctionTargetFieldId',
junctionFieldOptions[0].value,
'settings',
{
...currentSettings,
junctionTargetFieldId: junctionFieldOptions[0].value,
},
{
shouldDirty: true,
},
);
} else {
setValue('settings.junctionTargetFieldId', undefined, {
shouldDirty: true,
});
setValue(
'settings',
{
...currentSettings,
junctionTargetFieldId: undefined,
},
{
shouldDirty: true,
},
);
}
};
const handleSelectionChange = (selectedValue: string) => {
setValue('settings.junctionTargetFieldId', selectedValue, {
shouldDirty: true,
});
setValue(
'settings',
{
...currentSettings,
junctionTargetFieldId: selectedValue,
},
{
shouldDirty: true,
},
);
};
return (
@@ -136,7 +163,19 @@ export const SettingsDataModelFieldRelationJunctionForm = ({
<SettingsOptionCardContentToggle
Icon={IconLink}
title={t`This is a relation to a Junction Object`}
description={t`Will show linked records directly instead of intermediate junction record`}
description={
<Trans>
Build many-to-many relations.{' '}
<a
href={documentationUrl}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'underline', color: 'inherit' }}
>
Learn more
</a>
</Trans>
}
checked={isJunctionConfigEnabled}
onChange={handleJunctionToggle}
divider={isJunctionConfigEnabled}
@@ -1,15 +1,17 @@
const DOCUMENTATION_BASE_URL = 'https://docs.twenty.com';
const DOCUMENTATION_PATH = '/user-guide/introduction';
// Locales that have documentation translations available
const SUPPORTED_DOC_LOCALES = ['fr', 'pt', 'de', 'es', 'it', 'ja', 'ko', 'zh'];
import {
DOCUMENTATION_BASE_URL,
DOCUMENTATION_DEFAULT_LANGUAGE,
DOCUMENTATION_DEFAULT_PATH,
DOCUMENTATION_SUPPORTED_LANGUAGES,
type DocumentationPath,
} from 'twenty-shared/constants';
export const getDocumentationUrl = ({
locale,
path = DOCUMENTATION_PATH,
path = DOCUMENTATION_DEFAULT_PATH,
}: {
locale?: string | null;
path?: string;
path?: DocumentationPath | string;
}): string => {
if (!locale) {
return `${DOCUMENTATION_BASE_URL}${path}`;
@@ -18,7 +20,16 @@ export const getDocumentationUrl = ({
// Extract language code from locale (e.g., 'fr' from 'fr-FR')
const langCode = locale.split('-')[0].toLowerCase();
if (SUPPORTED_DOC_LOCALES.includes(langCode)) {
// English content is served at root path (no /l/en/ prefix)
if (langCode === DOCUMENTATION_DEFAULT_LANGUAGE) {
return `${DOCUMENTATION_BASE_URL}${path}`;
}
const isSupported = DOCUMENTATION_SUPPORTED_LANGUAGES.includes(
langCode as (typeof DOCUMENTATION_SUPPORTED_LANGUAGES)[number],
);
if (isSupported) {
return `${DOCUMENTATION_BASE_URL}/l/${langCode}${path}`;
}
@@ -164,7 +164,7 @@ export const WorkflowDropdownStepOutputItems = ({
updateStepFilter({
rawVariableName: getVariableTemplateFromPath({
stepId: step.id,
path: [...currentPath, 'id'],
path: [...currentPath, currentSubStep.object.fieldIdName ?? 'id'],
}),
isFullRecord: true,
});
@@ -15,7 +15,11 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { MetadataFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { STANDARD_OBJECTS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-object.constant';
@@ -115,32 +119,22 @@ export class BackfillOpportunityOwnerFieldCommand extends ActiveOrSuspendedWorks
return;
}
const customOwnerField = flatFieldMetadatas.find(
(field) =>
field.name === 'owner' &&
field.universalIdentifier !== ownerUniversalIdentifier,
);
const customOwnerField =
flatFieldMetadatas.find(
(field) =>
field.name === 'owner' &&
field.universalIdentifier !== ownerUniversalIdentifier,
// Some self hoster might have run the name renaming successfully but not the joinColumnName
// Rather than asking them to restore db also grabbing them here
) ?? flatFieldMetadatas.find((field) => field.name === 'ownerOld');
if (isDefined(customOwnerField)) {
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would rename custom owner field to 'ownerOld' in workspace ${workspaceId}`,
);
} else {
await this.fieldMetadataService.updateOneField({
updateFieldInput: {
id: customOwnerField.id,
name: 'ownerOld',
label: 'Owner (Old)',
},
workspaceId,
isSystemBuild: true,
});
this.logger.log(
`Renamed custom owner field to 'ownerOld' in workspace ${workspaceId}`,
);
}
await this.renameCustomOwnerFieldToOwnerOld({
customOwnerField,
flatFieldMetadataMaps,
workspaceId,
dryRun: options.dryRun,
});
}
if (options.dryRun) {
@@ -232,4 +226,96 @@ export class BackfillOpportunityOwnerFieldCommand extends ActiveOrSuspendedWorks
throw error;
}
}
private async renameCustomOwnerFieldToOwnerOld({
customOwnerField,
flatFieldMetadataMaps,
workspaceId,
dryRun,
}: {
customOwnerField: FlatFieldMetadata;
flatFieldMetadataMaps: MetadataFlatEntityMaps<'fieldMetadata'>;
workspaceId: string;
dryRun?: boolean;
}): Promise<void> {
if (dryRun) {
this.logger.log(
`[DRY RUN] Would rename custom owner field to 'ownerOld' in workspace and search for colliding foreignKey ${workspaceId}`,
);
return;
}
const isMorphOrRelationField =
isMorphOrRelationFlatFieldMetadata(customOwnerField);
const hasCollidingJoinColumnName =
isMorphOrRelationField &&
isDefined(customOwnerField.settings.joinColumnName) &&
customOwnerField.settings.joinColumnName === 'ownerId';
await this.fieldMetadataService.updateOneField({
updateFieldInput: {
id: customOwnerField.id,
name: 'ownerOld',
label: 'Owner (Old)',
...(hasCollidingJoinColumnName
? {
settings: {
...customOwnerField.settings,
joinColumnName: `ownerOldId`,
},
}
: {}),
},
workspaceId,
isSystemBuild: true,
});
this.logger.log(
`Renamed custom owner field to 'ownerOld' in workspace ${workspaceId}`,
);
if (!isMorphOrRelationField || hasCollidingJoinColumnName) {
return;
}
const relatedFlatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: customOwnerField.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (
!isDefined(relatedFlatFieldMetadata) ||
!isMorphOrRelationFlatFieldMetadata(relatedFlatFieldMetadata)
) {
this.logger.error(
`Could not find custom owner relation target field ${customOwnerField.relationTargetFieldMetadataId}`,
);
return;
}
const targetHasCollidingJoinColumnName =
isDefined(relatedFlatFieldMetadata.settings.joinColumnName) &&
relatedFlatFieldMetadata.settings.joinColumnName === 'ownerId';
if (!targetHasCollidingJoinColumnName) {
return;
}
await this.fieldMetadataService.updateOneField({
updateFieldInput: {
id: relatedFlatFieldMetadata.id,
settings: {
...relatedFlatFieldMetadata.settings,
joinColumnName: `ownerOldId`,
},
},
workspaceId,
isSystemBuild: true,
});
this.logger.log(
`Renamed custom owner field target related field join column name to 'ownerOldId' in workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,77 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:delete-file-records',
description: 'Delete all file records from FileRepository for workspace',
})
export class DeleteFileRecordsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(DeleteFileRecordsCommand.name);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun || false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting deletion of file records for workspace ${workspaceId}`,
);
const files = await this.fileRepository.find({
where: { workspaceId },
withDeleted: true,
});
if (files.length === 0) {
this.logger.log(
`No file records found for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] Would delete' : 'Deleting'} ${files.length} file record(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
try {
await this.fileRepository.delete({ workspaceId });
this.logger.log(
`Successfully deleted ${files.length} file record(s) for workspace ${workspaceId}`,
);
} catch (error) {
this.logger.error(
`Failed to delete file records for workspace ${workspaceId}: ${error.message}`,
);
throw error;
}
}
}
@@ -0,0 +1,54 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ALL_FLAT_ENTITY_MAPS_PROPERTIES } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-maps-properties.constant';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
@Command({
name: 'upgrade:1-16:flush-v2-cache-and-increment-metadata-version',
description: 'Flush the whole v2 cache and increment the metadata version',
})
export class FlushV2CacheAndIncrementMetadataVersionCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`Flushing v2 cache and incrementing metadata version for workspace ${workspaceId}`,
);
if (options.dryRun) {
this.logger.log(
`DRY RUN: Would flush v2 cache and increment metadata version for workspace ${workspaceId}`,
);
return;
}
await this.workspaceMigrationRunnerService.invalidateCache({
allFlatEntityMapsKeys: ALL_FLAT_ENTITY_MAPS_PROPERTIES,
workspaceId,
});
this.logger.log(
`Successfully flushed v2 cache and incremented metadata version for workspace ${workspaceId}`,
);
}
}
@@ -15,11 +15,11 @@ import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { TASK_TARGET_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-migration/constant/standard-field-ids';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Command({
name: 'upgrade:1-16:update-task-on-delete-action',
@@ -35,7 +35,7 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fieldMetadataService: FieldMetadataService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
@@ -47,24 +47,52 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
const isDryRun = options.dryRun ?? false;
this.logger.log(
`Running UpdateTaskOnDeleteActionCommand for workspace ${workspaceId}`,
`Running UpdateTaskOnDeleteActionCommand for workspace ${workspaceId} (dryRun: ${isDryRun})`,
);
this.logger.log(
`[Phase 1] Starting task relation onDelete action update for workspace ${workspaceId}`,
);
await this.updateTaskRelationOnDeleteAction(workspaceId, isDryRun);
this.logger.log(
`[Phase 1] Completed task relation onDelete action update for workspace ${workspaceId}`,
);
this.logger.log(
`[Phase 2] Starting orphaned taskTarget cleanup for workspace ${workspaceId}`,
);
await this.deleteOrphanedTaskTargets(workspaceId, isDryRun);
this.logger.log(
`[Phase 2] Completed orphaned taskTarget cleanup for workspace ${workspaceId}`,
);
this.logger.log(
`UpdateTaskOnDeleteActionCommand completed successfully for workspace ${workspaceId}`,
);
}
private async updateTaskRelationOnDeleteAction(
workspaceId: string,
isDryRun: boolean,
): Promise<void> {
this.logger.log(
`[Step 1/4] Fetching workspace cache for workspace ${workspaceId}`,
);
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
]);
this.logger.log(
`[Step 2/4] Looking for taskTarget object metadata in workspace ${workspaceId}`,
);
const taskTargetObjectMetadata = Object.values(
flatObjectMetadataMaps.byId,
).find(
@@ -80,6 +108,16 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
return;
}
this.logger.log(JSON.stringify(taskTargetObjectMetadata, null, 2));
this.logger.log(
`[Step 2/4] Found taskTarget object metadata with id ${taskTargetObjectMetadata.id} in workspace ${workspaceId}`,
);
this.logger.log(
`[Step 3/4] Looking for task field on taskTarget object in workspace ${workspaceId}`,
);
const taskTargetFields = findManyFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityIds: taskTargetObjectMetadata.fieldMetadataIds,
flatEntityMaps: flatFieldMetadataMaps,
@@ -97,9 +135,13 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
return;
}
this.logger.log(
`[Step 3/4] Found task field with id ${taskField.id} in workspace ${workspaceId}`,
);
if (taskField.type !== FieldMetadataType.RELATION) {
this.logger.warn(
`Task field is not a relation field in workspace ${workspaceId}`,
`Task field is not a relation field (type: ${taskField.type}) in workspace ${workspaceId}`,
);
return;
@@ -110,14 +152,14 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
if (taskFieldSettings?.onDelete === RelationOnDeleteAction.CASCADE) {
this.logger.log(
`Task relation already has CASCADE onDelete in workspace ${workspaceId}`,
`[Step 4/4] Task relation already has CASCADE onDelete in workspace ${workspaceId}, skipping update`,
);
return;
}
this.logger.log(
`Updating task relation onDelete from ${taskFieldSettings?.onDelete} to CASCADE in workspace ${workspaceId}`,
`[Step 4/4] Updating task relation onDelete from ${taskFieldSettings?.onDelete ?? 'undefined'} to CASCADE in workspace ${workspaceId}`,
);
if (!isDryRun) {
@@ -126,21 +168,72 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
onDelete: RelationOnDeleteAction.CASCADE,
};
await this.fieldMetadataService.updateOneField({
updateFieldInput: {
id: taskField.id,
settings: updatedSettings,
},
workspaceId,
isSystemBuild: true,
});
const updatedTaskField = {
...taskField,
settings: updatedSettings,
updatedAt: new Date().toISOString(),
};
this.logger.log(
`Successfully updated task relation onDelete to CASCADE in workspace ${workspaceId}`,
`[Step 4/4] Calling validateBuildAndRunWorkspaceMigration for field ${taskField.id} in workspace ${workspaceId}`,
);
try {
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [updatedTaskField],
},
},
workspaceId,
isSystemBuild: true,
},
);
this.logger.error(
`[Step 4/4] Validation result: ${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
if (isDefined(validateAndBuildResult)) {
this.logger.error(
`[Step 4/4] Failed to update task relation onDelete in workspace ${workspaceId}`,
);
throw new Error(
`Failed to update task relation onDelete in workspace ${workspaceId}`,
);
}
this.logger.log(
`[Step 4/4] Successfully updated task relation onDelete to CASCADE in workspace ${workspaceId}`,
);
} catch (error) {
this.logger.error(
`[Step 4/4] Failed to update task relation onDelete in workspace ${workspaceId}`,
);
this.logger.error(
`[Step 4/4] Field id: ${taskField.id}, current settings: ${JSON.stringify(taskFieldSettings)}`,
);
this.logger.error(
`[Step 4/4] Target settings: ${JSON.stringify(updatedSettings)}`,
);
this.logger.error(
`[Step 4/4] Error message: ${error instanceof Error ? error.message : String(error)}`,
);
this.logger.error(
`[Step 4/4] Error stack: ${error instanceof Error ? error.stack : 'No stack available'}`,
);
this.logger.error(JSON.stringify(error, null, 2));
throw error;
}
} else {
this.logger.log(
`DRY RUN: Would update task relation onDelete to CASCADE in workspace ${workspaceId}`,
`[Step 4/4] DRY RUN: Would update task relation onDelete to CASCADE in workspace ${workspaceId}`,
);
}
}
@@ -149,6 +242,10 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
workspaceId: string,
isDryRun: boolean,
): Promise<void> {
this.logger.log(
`[Orphan cleanup 1/3] Getting taskTarget repository for workspace ${workspaceId}`,
);
const taskTargetRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
@@ -156,6 +253,10 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
{ shouldBypassPermissionChecks: true },
);
this.logger.log(
`[Orphan cleanup 2/3] Searching for orphaned taskTarget records (taskId is null) in workspace ${workspaceId}`,
);
const orphanedTaskTargets = await taskTargetRepository.find({
withDeleted: true,
select: ['id'],
@@ -167,10 +268,14 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
const orphanedCount = orphanedTaskTargets.length;
this.logger.log(
`Found ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
`[Orphan cleanup 2/3] Found ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
);
if (orphanedCount === 0) {
this.logger.log(
`[Orphan cleanup 3/3] No orphaned records to delete in workspace ${workspaceId}`,
);
return;
}
@@ -178,23 +283,50 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
const orphanedIds = orphanedTaskTargets.map((record) => record.id);
const batchSize = 100;
const totalBatches = Math.ceil(orphanedIds.length / batchSize);
this.logger.log(
`[Orphan cleanup 3/3] Deleting ${orphanedCount} orphaned taskTarget record(s) in ${totalBatches} batch(es) in workspace ${workspaceId}`,
);
for (let i = 0; i < orphanedIds.length; i += batchSize) {
const batch = orphanedIds.slice(i, i + batchSize);
const batchNumber = Math.floor(i / batchSize) + 1;
await taskTargetRepository
.createQueryBuilder()
.delete()
.whereInIds(batch)
.execute();
this.logger.log(
`[Orphan cleanup 3/3] Deleting batch ${batchNumber}/${totalBatches} (${batch.length} records) in workspace ${workspaceId}`,
);
try {
await taskTargetRepository
.createQueryBuilder()
.delete()
.whereInIds(batch)
.execute();
} catch (error) {
this.logger.error(
`[Orphan cleanup 3/3] Failed to delete batch ${batchNumber}/${totalBatches} in workspace ${workspaceId}`,
);
this.logger.error(
`[Orphan cleanup 3/3] Batch record ids: ${JSON.stringify(batch)}`,
);
this.logger.error(
`[Orphan cleanup 3/3] Error message: ${error instanceof Error ? error.message : String(error)}`,
);
this.logger.error(
`[Orphan cleanup 3/3] Error stack: ${error instanceof Error ? error.stack : 'No stack available'}`,
);
throw error;
}
}
this.logger.log(
`Deleted ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
`[Orphan cleanup 3/3] Successfully deleted ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
);
} else {
this.logger.log(
`DRY RUN: Would delete ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
`[Orphan cleanup 3/3] DRY RUN: Would delete ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
);
}
}
@@ -3,10 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillOpportunityOwnerFieldCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-opportunity-owner-field.command';
import { BackfillStandardPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-standard-page-layouts.command';
import { DeleteFileRecordsCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-delete-all-files.command';
import { FlushV2CacheAndIncrementMetadataVersionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-flush-v2-cache-and-increment-metadata-version.command';
import { IdentifyAgentMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-agent-metadata.command';
import { IdentifyFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-field-metadata.command';
import { IdentifyIndexMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-index-metadata.command';
import { IdentifyObjectMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-object-metadata.command';
import { IdentifyRemainingEntitiesMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-remaining-entities-metadata.command';
import { IdentifyRoleMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-role-metadata.command';
import { IdentifyViewFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-field-metadata.command';
import { IdentifyViewFilterMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-filter-metadata.command';
@@ -16,7 +19,6 @@ import { MakeAgentUniversalIdentifierAndApplicationIdNotNullableMigrationCommand
import { MakeFieldMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-field-metadata-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-index-metadata-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakeObjectMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-object-metadata-universal-identifier-and-application-id-not-nullable-migration.command';
import { IdentifyRemainingEntitiesMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-remaining-entities-metadata.command';
import { MakeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-remaining-entities-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakeRoleUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-role-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakeViewFieldUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-view-field-universal-identifier-and-application-id-not-nullable-migration.command';
@@ -25,6 +27,7 @@ import { MakeViewGroupUniversalIdentifierAndApplicationIdNotNullableMigrationCom
import { MakeViewUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-view-universal-identifier-and-application-id-not-nullable-migration.command';
import { UpdateTaskOnDeleteActionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@@ -41,6 +44,7 @@ import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entit
import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { TwentyStandardApplicationModule } from 'src/engine/workspace-manager/twenty-standard-application/twenty-standard-application.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
@@ -56,6 +60,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
ViewFieldEntity,
ViewFilterEntity,
ViewGroupEntity,
FileEntity,
]),
DataSourceModule,
WorkspaceCacheModule,
@@ -64,12 +69,14 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
GlobalWorkspaceDataSourceModule,
TwentyStandardApplicationModule,
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [
UpdateTaskOnDeleteActionCommand,
BackfillOpportunityOwnerFieldCommand,
BackfillStandardPageLayoutsCommand,
DeleteFileRecordsCommand,
IdentifyAgentMetadataCommand,
IdentifyFieldMetadataCommand,
IdentifyIndexMetadataCommand,
@@ -90,11 +97,13 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
MakeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
MakeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
IdentifyRemainingEntitiesMetadataCommand,
FlushV2CacheAndIncrementMetadataVersionCommand,
],
exports: [
UpdateTaskOnDeleteActionCommand,
BackfillOpportunityOwnerFieldCommand,
BackfillStandardPageLayoutsCommand,
DeleteFileRecordsCommand,
IdentifyAgentMetadataCommand,
IdentifyFieldMetadataCommand,
IdentifyIndexMetadataCommand,
@@ -115,6 +124,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
MakeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
MakeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
IdentifyRemainingEntitiesMetadataCommand,
FlushV2CacheAndIncrementMetadataVersionCommand,
],
})
export class V1_16_UpgradeVersionCommandModule {}
@@ -24,14 +24,16 @@ import { FixNanPositionValuesInNotesCommand } from 'src/database/commands/upgrad
import { MigratePageLayoutWidgetConfigurationCommand } from 'src/database/commands/upgrade-version-command/1-15/1-15-migrate-page-layout-widget-configuration.command';
import { BackfillOpportunityOwnerFieldCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-opportunity-owner-field.command';
import { BackfillStandardPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-standard-page-layouts.command';
import { DeleteFileRecordsCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-delete-all-files.command';
import { FlushV2CacheAndIncrementMetadataVersionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-flush-v2-cache-and-increment-metadata-version.command';
import { IdentifyAgentMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-agent-metadata.command';
import { IdentifyFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-field-metadata.command';
import { IdentifyIndexMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-index-metadata.command';
import { IdentifyObjectMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-object-metadata.command';
import { IdentifyRemainingEntitiesMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-remaining-entities-metadata.command';
import { IdentifyRoleMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-role-metadata.command';
import { IdentifyViewFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-field-metadata.command';
import { IdentifyViewFilterMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-filter-metadata.command';
import { IdentifyRemainingEntitiesMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-remaining-entities-metadata.command';
import { IdentifyViewGroupMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-group-metadata.command';
import { IdentifyViewMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-metadata.command';
import { MakeAgentUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-agent-universal-identifier-and-application-id-not-nullable-migration.command';
@@ -107,6 +109,8 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly makeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
protected readonly identifyRemainingEntitiesMetadataCommand: IdentifyRemainingEntitiesMetadataCommand,
protected readonly makeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
protected readonly flushV2CacheAndIncrementMetadataVersionCommand: FlushV2CacheAndIncrementMetadataVersionCommand,
protected readonly deleteFileRecordsCommand: DeleteFileRecordsCommand,
) {
super(
workspaceRepository,
@@ -141,9 +145,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
];
const commands_1160: VersionCommands = [
this.updateTaskOnDeleteActionCommand,
this.backfillOpportunityOwnerFieldCommand,
this.backfillStandardPageLayoutsCommand,
this.identifyAgentMetadataCommand,
this.identifyFieldMetadataCommand,
this.identifyObjectMetadataCommand,
@@ -153,6 +154,8 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.identifyViewFilterMetadataCommand,
this.identifyViewGroupMetadataCommand,
this.identifyIndexMetadataCommand,
this.backfillOpportunityOwnerFieldCommand,
this.backfillStandardPageLayoutsCommand,
this
.makeAgentUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
this
@@ -174,6 +177,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.identifyRemainingEntitiesMetadataCommand,
this
.makeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
this.flushV2CacheAndIncrementMetadataVersionCommand,
this.updateTaskOnDeleteActionCommand,
this.deleteFileRecordsCommand,
];
this.allCommands = {
@@ -6,6 +6,7 @@ import {
} from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
@@ -38,12 +39,18 @@ export class WorkspaceMigrationValidateBuildAndRunService {
private readonly logger = new Logger(
WorkspaceMigrationValidateBuildAndRunService.name,
);
private readonly isDebugEnabled: boolean;
constructor(
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMigrationBuildOrchestratorService: WorkspaceMigrationBuildOrchestratorService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
twentyConfigService: TwentyConfigService,
) {
const logLevels = twentyConfigService.get('LOG_LEVELS');
this.isDebugEnabled = logLevels.includes('debug');
}
private async computeAllRelatedFlatEntityMaps({
allFlatEntityOperationByMetadataName,
@@ -186,6 +193,10 @@ export class WorkspaceMigrationValidateBuildAndRunService {
});
if (validateAndBuildResult.status === 'fail') {
if (this.isDebugEnabled) {
this.logger.debug(JSON.stringify(validateAndBuildResult, null, 2));
}
return validateAndBuildResult;
}
@@ -0,0 +1,165 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command, Option } from 'nest-commander';
import {
ALL_METADATA_NAME,
type AllMetadataName,
} from 'twenty-shared/metadata';
import { Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type ActiveOrSuspendedWorkspacesMigrationCommandOptions,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
type FlatCacheFlushCommandOptions =
ActiveOrSuspendedWorkspacesMigrationCommandOptions & {
allMetadata?: boolean;
};
@Command({
name: 'cache:flat-cache-invalidate',
description:
'Flush flat entity cache for specific metadata names and workspaces',
})
export class FlatCacheInvalidateCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner<FlatCacheFlushCommandOptions> {
private metadataNames: string[] = [];
private flatMapsKeysToFlush: (keyof AllFlatEntityMaps)[] = [];
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
@Option({
flags: '--metadataName <metadataName>',
description:
'Metadata name(s) to flush cache for. Can be specified multiple times.',
required: false,
})
parseMetadataName(val: string): string[] {
this.metadataNames.push(val);
return this.metadataNames;
}
@Option({
flags: '--all-metadata',
description:
'Flush cache for all metadata names. Takes precedence over --metadataName.',
required: false,
})
parseAllMetadata(): boolean {
return true;
}
override async runMigrationCommand(
passedParams: string[],
options: FlatCacheFlushCommandOptions,
): Promise<void> {
if (!options.allMetadata && this.metadataNames.length === 0) {
this.logger.error(
'Either --all-metadata or at least one --metadataName must be provided.',
);
return;
}
const validatedMetadataNames = this.validateAndExpandMetadataNames({
inputMetadataNames: this.metadataNames,
allMetadata: options.allMetadata,
});
if (validatedMetadataNames === null) {
return;
}
this.flatMapsKeysToFlush = this.computeFlatMapsKeysWithRelated(
validatedMetadataNames,
);
this.logger.log(
`Will flush cache for the following flat maps keys: ${this.flatMapsKeysToFlush.join(', ')}`,
);
await super.runMigrationCommand(passedParams, options);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
await this.workspaceMigrationRunnerService.invalidateCache({
allFlatEntityMapsKeys: this.flatMapsKeysToFlush,
workspaceId,
});
this.logger.log(
`Successfully invalidated cache for workspace: ${workspaceId}`,
);
}
private validateAndExpandMetadataNames({
inputMetadataNames,
allMetadata,
}: {
inputMetadataNames: string[];
allMetadata?: boolean;
}): AllMetadataName[] | null {
const validMetadataNames = Object.keys(
ALL_METADATA_NAME,
) as AllMetadataName[];
if (allMetadata) {
this.logger.log('Using all metadata names');
return validMetadataNames;
}
const invalidNames = inputMetadataNames.filter(
(name) => !validMetadataNames.includes(name as AllMetadataName),
);
if (invalidNames.length > 0) {
this.logger.error(
`Invalid metadata name(s) provided: ${invalidNames.join(', ')}`,
);
this.logger.error(
`Valid metadata names are: ${validMetadataNames.join(', ')}, or use --all-metadata`,
);
return null;
}
return inputMetadataNames as AllMetadataName[];
}
private computeFlatMapsKeysWithRelated(
metadataNames: AllMetadataName[],
): ReturnType<typeof getMetadataFlatEntityMapsKey>[] {
const allMetadataNamesToFlush = [
...new Set([
...metadataNames,
...metadataNames.flatMap(getMetadataRelatedMetadataNames),
]),
];
const allFlatMapsKeys = allMetadataNamesToFlush.map(
getMetadataFlatEntityMapsKey,
);
return allFlatMapsKeys;
}
}
@@ -14,7 +14,6 @@ import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/wor
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration';
import { WorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
import {
WorkspaceMigrationRunnerException,
WorkspaceMigrationRunnerExceptionCode,
@@ -35,19 +34,18 @@ export class WorkspaceMigrationRunnerService {
) {}
private getLegacyCacheInvalidationPromises({
workspaceMigration: { actions, workspaceId },
allFlatEntityMapsKeys,
workspaceId,
}: {
workspaceMigration: Omit<WorkspaceMigration, 'relatedFlatEntityMapsKeys'>;
allFlatEntityMapsKeys: (keyof AllFlatEntityMaps)[];
workspaceId: string;
}): Promise<void>[] {
const asyncOperations: Promise<void>[] = [];
const shouldIncrementMetadataGraphqlSchemaVersion = actions.some(
(action) => {
return (
action.metadataName === 'objectMetadata' ||
action.metadataName === 'fieldMetadata'
);
},
);
const flatMapsKeysSet = new Set(allFlatEntityMapsKeys);
const shouldIncrementMetadataGraphqlSchemaVersion =
flatMapsKeysSet.has('flatObjectMetadataMaps') ||
flatMapsKeysSet.has('flatFieldMetadataMaps');
if (shouldIncrementMetadataGraphqlSchemaVersion) {
asyncOperations.push(
@@ -57,16 +55,15 @@ export class WorkspaceMigrationRunnerService {
);
}
const viewRelatedMetadataNames = [
'view',
'viewFilter',
'viewGroup',
'viewField',
'viewFilterGroup',
const viewRelatedFlatMapsKeys: (keyof AllFlatEntityMaps)[] = [
'flatViewMaps',
'flatViewFilterMaps',
'flatViewGroupMaps',
'flatViewFieldMaps',
'flatViewFilterGroupMaps',
];
const shouldInvalidFindCoreViewsGraphqlCacheOperation = actions.some(
(action) => viewRelatedMetadataNames.includes(action.metadataName),
);
const shouldInvalidFindCoreViewsGraphqlCacheOperation =
viewRelatedFlatMapsKeys.some((key) => flatMapsKeysSet.has(key));
if (
shouldInvalidFindCoreViewsGraphqlCacheOperation ||
@@ -80,11 +77,9 @@ export class WorkspaceMigrationRunnerService {
);
}
const shouldInvalidateRoleMapCache = actions.some((action) => {
return (
action.metadataName === 'role' || action.metadataName === 'roleTarget'
);
});
const shouldInvalidateRoleMapCache =
flatMapsKeysSet.has('flatRoleMaps') ||
flatMapsKeysSet.has('flatRoleTargetMaps');
if (
shouldIncrementMetadataGraphqlSchemaVersion ||
@@ -105,14 +100,12 @@ export class WorkspaceMigrationRunnerService {
return asyncOperations;
}
private async invalidateCachePostExecution({
async invalidateCache({
allFlatEntityMapsKeys,
workspaceId,
actions,
}: {
allFlatEntityMapsKeys: (keyof AllFlatEntityMaps)[];
workspaceId: string;
actions: WorkspaceMigrationAction[];
}): Promise<void> {
this.logger.time(
'Runner',
@@ -126,10 +119,8 @@ export class WorkspaceMigrationRunnerService {
const invalidationResults = await Promise.allSettled(
this.getLegacyCacheInvalidationPromises({
workspaceMigration: {
actions,
workspaceId,
},
allFlatEntityMapsKeys,
workspaceId,
}),
);
@@ -214,10 +205,9 @@ export class WorkspaceMigrationRunnerService {
this.logger.timeEnd('Runner', 'Transaction execution');
await this.invalidateCachePostExecution({
await this.invalidateCache({
allFlatEntityMapsKeys,
workspaceId,
actions,
});
this.logger.timeEnd('Runner', 'Total execution');
@@ -1,14 +1,17 @@
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceSchemaMigrationRunnerActionHandlersModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module';
import { FlatCacheInvalidateCommand } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/commands/flat-cache-invalidate.command';
import { WorkspaceMigrationRunnerActionHandlerRegistryService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/registry/workspace-migration-runner-action-handler-registry.service';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
@@ -23,10 +26,12 @@ import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/wo
DiscoveryModule,
WorkspaceCacheStorageModule,
WorkspaceCacheModule,
TypeOrmModule.forFeature([WorkspaceEntity]),
],
providers: [
WorkspaceMigrationRunnerService,
WorkspaceMigrationRunnerActionHandlerRegistryService,
FlatCacheInvalidateCommand,
],
exports: [WorkspaceMigrationRunnerService],
})
@@ -0,0 +1 @@
export const DOCUMENTATION_BASE_URL = 'https://docs.twenty.com';
@@ -0,0 +1 @@
export const DOCUMENTATION_DEFAULT_LANGUAGE = 'en' as const;
@@ -0,0 +1,4 @@
import { DOCUMENTATION_PATHS } from './DocumentationPaths';
export const DOCUMENTATION_DEFAULT_PATH =
DOCUMENTATION_PATHS.USER_GUIDE_INTRODUCTION;
@@ -0,0 +1,298 @@
/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
* | | \ V V / __/ | | | |_| |_| | DO NOT EDIT - changes will be overwritten
* |_| \_/\_/ \___|_| |_|\__|\__, | Generated by: yarn docs:generate-paths
* |___/
*
* This file is generated from packages/twenty-docs/navigation/base-structure.json
* To add new documentation pages, add them to base-structure.json and run:
* yarn docs:generate-paths
*/
export const DOCUMENTATION_PATHS = {
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_BEST_PRACTICES_SERVER:
'/developers/contribute/capabilities/backend-development/best-practices-server',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_CUSTOM_OBJECTS:
'/developers/contribute/capabilities/backend-development/custom-objects',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_FEATURE_FLAGS:
'/developers/contribute/capabilities/backend-development/feature-flags',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_FOLDER_ARCHITECTURE_SERVER:
'/developers/contribute/capabilities/backend-development/folder-architecture-server',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_QUEUE:
'/developers/contribute/capabilities/backend-development/queue',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_SERVER_COMMANDS:
'/developers/contribute/capabilities/backend-development/server-commands',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_ZAPIER:
'/developers/contribute/capabilities/backend-development/zapier',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BUG_AND_REQUESTS:
'/developers/contribute/capabilities/bug-and-requests',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_BEST_PRACTICES_FRONT:
'/developers/contribute/capabilities/frontend-development/best-practices-front',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_FOLDER_ARCHITECTURE_FRONT:
'/developers/contribute/capabilities/frontend-development/folder-architecture-front',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_FRONTEND_COMMANDS:
'/developers/contribute/capabilities/frontend-development/frontend-commands',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_HOTKEYS:
'/developers/contribute/capabilities/frontend-development/hotkeys',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_STORYBOOK:
'/developers/contribute/capabilities/frontend-development/storybook',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_STYLE_GUIDE:
'/developers/contribute/capabilities/frontend-development/style-guide',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_WORK_WITH_FIGMA:
'/developers/contribute/capabilities/frontend-development/work-with-figma',
DEVELOPERS_CONTRIBUTE_CAPABILITIES_LOCAL_SETUP:
'/developers/contribute/capabilities/local-setup',
DEVELOPERS_CONTRIBUTE_CONTRIBUTE: '/developers/contribute/contribute',
DEVELOPERS_EXTEND_CAPABILITIES_APIS: '/developers/extend/capabilities/apis',
DEVELOPERS_EXTEND_CAPABILITIES_APPS: '/developers/extend/capabilities/apps',
DEVELOPERS_EXTEND_CAPABILITIES_WEBHOOKS:
'/developers/extend/capabilities/webhooks',
DEVELOPERS_EXTEND_EXTEND: '/developers/extend/extend',
DEVELOPERS_INTRODUCTION: '/developers/introduction',
DEVELOPERS_SELF_HOST_CAPABILITIES_CLOUD_PROVIDERS:
'/developers/self-host/capabilities/cloud-providers',
DEVELOPERS_SELF_HOST_CAPABILITIES_DOCKER_COMPOSE:
'/developers/self-host/capabilities/docker-compose',
DEVELOPERS_SELF_HOST_CAPABILITIES_SETUP:
'/developers/self-host/capabilities/setup',
DEVELOPERS_SELF_HOST_CAPABILITIES_TROUBLESHOOTING:
'/developers/self-host/capabilities/troubleshooting',
DEVELOPERS_SELF_HOST_CAPABILITIES_UPGRADE_GUIDE:
'/developers/self-host/capabilities/upgrade-guide',
DEVELOPERS_SELF_HOST_SELF_HOST: '/developers/self-host/self-host',
TWENTY_UI_DISPLAY_APP_TOOLTIP: '/twenty-ui/display/app-tooltip',
TWENTY_UI_DISPLAY_CHECKMARK: '/twenty-ui/display/checkmark',
TWENTY_UI_DISPLAY_CHIP: '/twenty-ui/display/chip',
TWENTY_UI_DISPLAY_ICONS: '/twenty-ui/display/icons',
TWENTY_UI_DISPLAY_SOON_PILL: '/twenty-ui/display/soon-pill',
TWENTY_UI_DISPLAY_TAG: '/twenty-ui/display/tag',
TWENTY_UI_INPUT_BLOCK_EDITOR: '/twenty-ui/input/block-editor',
TWENTY_UI_INPUT_BUTTONS: '/twenty-ui/input/buttons',
TWENTY_UI_INPUT_CHECKBOX: '/twenty-ui/input/checkbox',
TWENTY_UI_INPUT_COLOR_SCHEME: '/twenty-ui/input/color-scheme',
TWENTY_UI_INPUT_ICON_PICKER: '/twenty-ui/input/icon-picker',
TWENTY_UI_INPUT_IMAGE_INPUT: '/twenty-ui/input/image-input',
TWENTY_UI_INPUT_RADIO: '/twenty-ui/input/radio',
TWENTY_UI_INPUT_SELECT: '/twenty-ui/input/select',
TWENTY_UI_INPUT_TEXT: '/twenty-ui/input/text',
TWENTY_UI_INPUT_TOGGLE: '/twenty-ui/input/toggle',
TWENTY_UI_INTRODUCTION: '/twenty-ui/introduction',
TWENTY_UI_NAVIGATION: '/twenty-ui/navigation',
TWENTY_UI_NAVIGATION_BREADCRUMB: '/twenty-ui/navigation/breadcrumb',
TWENTY_UI_NAVIGATION_LINKS: '/twenty-ui/navigation/links',
TWENTY_UI_NAVIGATION_MENU_ITEM: '/twenty-ui/navigation/menu-item',
TWENTY_UI_NAVIGATION_NAVIGATION_BAR: '/twenty-ui/navigation/navigation-bar',
TWENTY_UI_NAVIGATION_STEP_BAR: '/twenty-ui/navigation/step-bar',
TWENTY_UI_PROGRESS_BAR: '/twenty-ui/progress-bar',
USER_GUIDE_AI_CAPABILITIES_AI_AGENTS: '/user-guide/ai/capabilities/ai-agents',
USER_GUIDE_AI_CAPABILITIES_AI_CHATBOT:
'/user-guide/ai/capabilities/ai-chatbot',
USER_GUIDE_AI_CAPABILITIES_PERMISSIONS_ACCESS_CONTROL:
'/user-guide/ai/capabilities/permissions-access-control',
USER_GUIDE_AI_HOW_TOS_AI_FAQ: '/user-guide/ai/how-tos/ai-faq',
USER_GUIDE_AI_OVERVIEW: '/user-guide/ai/overview',
USER_GUIDE_BILLING_CAPABILITIES_PRICING_PLANS:
'/user-guide/billing/capabilities/pricing-plans',
USER_GUIDE_BILLING_CAPABILITIES_WORKFLOW_CREDITS:
'/user-guide/billing/capabilities/workflow-credits',
USER_GUIDE_BILLING_HOW_TOS_BILLING_FAQ:
'/user-guide/billing/how-tos/billing-faq',
USER_GUIDE_BILLING_OVERVIEW: '/user-guide/billing/overview',
USER_GUIDE_CALENDAR_EMAILS_CAPABILITIES_CALENDAR:
'/user-guide/calendar-emails/capabilities/calendar',
USER_GUIDE_CALENDAR_EMAILS_CAPABILITIES_MAILBOX:
'/user-guide/calendar-emails/capabilities/mailbox',
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_CAN_I_BOOK_MEETINGS_FROM_TWENTY:
'/user-guide/calendar-emails/how-tos/can-i-book-meetings-from-twenty',
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_CAN_I_SEND_EMAILS_FROM_TWENTY:
'/user-guide/calendar-emails/how-tos/can-i-send-emails-from-twenty',
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_CAN_I_TRACK_EMAIL_ACTIVITY_ON_ALL_OBJECTS:
'/user-guide/calendar-emails/how-tos/can-i-track-email-activity-on-all-objects',
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_CONNECT_SEVERAL_MAILBOXES_PER_USER:
'/user-guide/calendar-emails/how-tos/connect-several-mailboxes-per-user',
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_I_DONT_SEE_EMAILS_ON_RECORDS:
'/user-guide/calendar-emails/how-tos/i-dont-see-emails-on-records',
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_LIMIT_EMAILS_IMPORTED:
'/user-guide/calendar-emails/how-tos/limit-emails-imported',
USER_GUIDE_CALENDAR_EMAILS_OVERVIEW: '/user-guide/calendar-emails/overview',
USER_GUIDE_DASHBOARDS_CAPABILITIES_CHART_SETTINGS:
'/user-guide/dashboards/capabilities/chart-settings',
USER_GUIDE_DASHBOARDS_CAPABILITIES_DASHBOARDS:
'/user-guide/dashboards/capabilities/dashboards',
USER_GUIDE_DASHBOARDS_CAPABILITIES_WIDGETS:
'/user-guide/dashboards/capabilities/widgets',
USER_GUIDE_DASHBOARDS_HOW_TOS_DASHBOARDS_FAQ:
'/user-guide/dashboards/how-tos/dashboards-faq',
USER_GUIDE_DASHBOARDS_HOW_TOS_WIDGET_FAQ:
'/user-guide/dashboards/how-tos/widget-faq',
USER_GUIDE_DASHBOARDS_OVERVIEW: '/user-guide/dashboards/overview',
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_ERROR_HANDLING:
'/user-guide/data-migration/capabilities/error-handling',
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_FIELD_MAPPING:
'/user-guide/data-migration/capabilities/field-mapping',
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_FILE_FORMATS:
'/user-guide/data-migration/capabilities/file-formats',
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_IMPORT_RELATIONS:
'/user-guide/data-migration/capabilities/import-relations',
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_UNIQUENESS_CONSTRAINTS:
'/user-guide/data-migration/capabilities/uniqueness-constraints',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_EXPORT_YOUR_DATA:
'/user-guide/data-migration/how-tos/export-your-data',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_FIX_IMPORT_ERRORS:
'/user-guide/data-migration/how-tos/fix-import-errors',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_IMPORT_COMPANIES_VIA_CSV:
'/user-guide/data-migration/how-tos/import-companies-via-csv',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_IMPORT_CONTACTS_VIA_CSV:
'/user-guide/data-migration/how-tos/import-contacts-via-csv',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_IMPORT_DATA_VIA_API:
'/user-guide/data-migration/how-tos/import-data-via-api',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_IMPORT_RELATIONS_BETWEEN_OBJECTS_VIA_CSV:
'/user-guide/data-migration/how-tos/import-relations-between-objects-via-csv',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_MIGRATING_FROM_OTHER_CRMS:
'/user-guide/data-migration/how-tos/migrating-from-other-crms',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_MIGRATING_FROM_SELF_HOSTED_TO_CLOUD:
'/user-guide/data-migration/how-tos/migrating-from-self-hosted-to-cloud',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_PREPARE_YOUR_CSV_FILES:
'/user-guide/data-migration/how-tos/prepare-your-csv-files',
USER_GUIDE_DATA_MIGRATION_HOW_TOS_UPDATE_EXISTING_RECORDS_VIA_IMPORT:
'/user-guide/data-migration/how-tos/update-existing-records-via-import',
USER_GUIDE_DATA_MIGRATION_OVERVIEW: '/user-guide/data-migration/overview',
USER_GUIDE_DATA_MODEL_CAPABILITIES_FIELDS:
'/user-guide/data-model/capabilities/fields',
USER_GUIDE_DATA_MODEL_CAPABILITIES_OBJECTS:
'/user-guide/data-model/capabilities/objects',
USER_GUIDE_DATA_MODEL_CAPABILITIES_RELATION_FIELDS:
'/user-guide/data-model/capabilities/relation-fields',
USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_CUSTOM_FIELDS:
'/user-guide/data-model/how-tos/create-custom-fields',
USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_CUSTOM_OBJECTS:
'/user-guide/data-model/how-tos/create-custom-objects',
USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_MANY_TO_MANY_RELATIONS:
'/user-guide/data-model/how-tos/create-many-to-many-relations',
USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_RELATION_FIELDS:
'/user-guide/data-model/how-tos/create-relation-fields',
USER_GUIDE_DATA_MODEL_HOW_TOS_CUSTOMIZE_YOUR_DATA_MODEL:
'/user-guide/data-model/how-tos/customize-your-data-model',
USER_GUIDE_DATA_MODEL_HOW_TOS_DATA_MODEL_FAQ:
'/user-guide/data-model/how-tos/data-model-faq',
USER_GUIDE_DATA_MODEL_OVERVIEW: '/user-guide/data-model/overview',
USER_GUIDE_GETTING_STARTED_CAPABILITIES_GLOSSARY:
'/user-guide/getting-started/capabilities/glossary',
USER_GUIDE_GETTING_STARTED_CAPABILITIES_IMPLEMENTATION_SERVICES:
'/user-guide/getting-started/capabilities/implementation-services',
USER_GUIDE_GETTING_STARTED_CAPABILITIES_WHAT_IS_TWENTY:
'/user-guide/getting-started/capabilities/what-is-twenty',
USER_GUIDE_GETTING_STARTED_HOW_TOS_CONFIGURE_YOUR_WORKSPACE:
'/user-guide/getting-started/how-tos/configure-your-workspace',
USER_GUIDE_GETTING_STARTED_HOW_TOS_CREATE_WORKSPACE:
'/user-guide/getting-started/how-tos/create-workspace',
USER_GUIDE_GETTING_STARTED_HOW_TOS_NAVIGATE_AROUND_TWENTY:
'/user-guide/getting-started/how-tos/navigate-around-twenty',
USER_GUIDE_INTRODUCTION: '/user-guide/introduction',
USER_GUIDE_PERMISSIONS_ACCESS_CAPABILITIES_PERMISSIONS:
'/user-guide/permissions-access/capabilities/permissions',
USER_GUIDE_PERMISSIONS_ACCESS_CAPABILITIES_SSO_CONFIGURATION:
'/user-guide/permissions-access/capabilities/sso-configuration',
USER_GUIDE_PERMISSIONS_ACCESS_HOW_TOS_PERMISSIONS_FAQ:
'/user-guide/permissions-access/how-tos/permissions-faq',
USER_GUIDE_PERMISSIONS_ACCESS_OVERVIEW:
'/user-guide/permissions-access/overview',
USER_GUIDE_SETTINGS_CAPABILITIES_DOMAINS_SETTINGS:
'/user-guide/settings/capabilities/domains-settings',
USER_GUIDE_SETTINGS_CAPABILITIES_EXPERIENCE_SETTINGS:
'/user-guide/settings/capabilities/experience-settings',
USER_GUIDE_SETTINGS_CAPABILITIES_MEMBER_MANAGEMENT:
'/user-guide/settings/capabilities/member-management',
USER_GUIDE_SETTINGS_CAPABILITIES_PROFILE_SETTINGS:
'/user-guide/settings/capabilities/profile-settings',
USER_GUIDE_SETTINGS_CAPABILITIES_UPDATES_SETTINGS:
'/user-guide/settings/capabilities/updates-settings',
USER_GUIDE_SETTINGS_CAPABILITIES_WORKSPACE_SETTINGS:
'/user-guide/settings/capabilities/workspace-settings',
USER_GUIDE_SETTINGS_HOW_TOS_SETTINGS_FAQ:
'/user-guide/settings/how-tos/settings-faq',
USER_GUIDE_SETTINGS_OVERVIEW: '/user-guide/settings/overview',
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_CALENDAR_VIEW:
'/user-guide/views-pipelines/capabilities/calendar-view',
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_FIELDS_AND_COLUMNS:
'/user-guide/views-pipelines/capabilities/fields-and-columns',
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_FILTERS_AND_SORTING:
'/user-guide/views-pipelines/capabilities/filters-and-sorting',
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_KANBAN_VIEWS:
'/user-guide/views-pipelines/capabilities/kanban-views',
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_TABLE_VIEWS:
'/user-guide/views-pipelines/capabilities/table-views',
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_VIEW_SETTINGS:
'/user-guide/views-pipelines/capabilities/view-settings',
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_CREATE_A_CALENDAR_VIEW_FOR_TASKS_DUE:
'/user-guide/views-pipelines/how-tos/create-a-calendar-view-for-tasks-due',
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_CREATE_A_KANBAN_VIEW_FOR_PROJECTS:
'/user-guide/views-pipelines/how-tos/create-a-kanban-view-for-projects',
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_CREATE_A_TABLE_VIEW_WITH_GROUPING:
'/user-guide/views-pipelines/how-tos/create-a-table-view-with-grouping',
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_RESTRICT_ACCESS_TO_YOUR_VIEW:
'/user-guide/views-pipelines/how-tos/restrict-access-to-your-view',
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_SET_UP_A_SALES_PIPELINE:
'/user-guide/views-pipelines/how-tos/set-up-a-sales-pipeline',
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_SHOW_EXPECTED_AMOUNT_IN_PIPELINE:
'/user-guide/views-pipelines/how-tos/show-expected-amount-in-pipeline',
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_TRACK_TIME_IN_STAGE:
'/user-guide/views-pipelines/how-tos/track-time-in-stage',
USER_GUIDE_VIEWS_PIPELINES_OVERVIEW: '/user-guide/views-pipelines/overview',
USER_GUIDE_WORKFLOWS_CAPABILITIES_SEND_EMAILS_FROM_WORKFLOWS:
'/user-guide/workflows/capabilities/send-emails-from-workflows',
USER_GUIDE_WORKFLOWS_CAPABILITIES_USE_BRANCHES_IN_WORKFLOWS:
'/user-guide/workflows/capabilities/use-branches-in-workflows',
USER_GUIDE_WORKFLOWS_CAPABILITIES_USE_ITERATOR:
'/user-guide/workflows/capabilities/use-iterator',
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_ACTIONS:
'/user-guide/workflows/capabilities/workflow-actions',
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_BRANCHES:
'/user-guide/workflows/capabilities/workflow-branches',
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_CREDITS:
'/user-guide/workflows/capabilities/workflow-credits',
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_RUNS:
'/user-guide/workflows/capabilities/workflow-runs',
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_TRIGGERS:
'/user-guide/workflows/capabilities/workflow-triggers',
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_VERSIONS:
'/user-guide/workflows/capabilities/workflow-versions',
USER_GUIDE_WORKFLOWS_HOW_TOS_ADVANCED_CONFIGURATIONS_HANDLE_ARRAYS_IN_CODE_ACTIONS:
'/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions',
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_BRING_PRODUCT_DATA_IN_TWENTY:
'/user-guide/workflows/how-tos/connect-to-other-tools/bring-product-data-in-twenty',
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_BRING_TYPEFORM_SUBMISSIONS_IN_TWENTY:
'/user-guide/workflows/how-tos/connect-to-other-tools/bring-typeform-submissions-in-twenty',
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_GENERATE_PDF_FROM_TWENTY:
'/user-guide/workflows/how-tos/connect-to-other-tools/generate-pdf-from-twenty',
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_GENERATE_QUOTE_OR_INVOICE_FROM_TWENTY:
'/user-guide/workflows/how-tos/connect-to-other-tools/generate-quote-or-invoice-from-twenty',
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_SET_UP_A_WEBHOOK_TRIGGER:
'/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger',
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_CLOSED_WON_AUTOMATIONS:
'/user-guide/workflows/how-tos/crm-automations/closed-won-automations',
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_DETECT_STALE_OPPORTUNITIES:
'/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities',
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_DISPLAY_NUMBER_OF_EMAILS_RECEIVED:
'/user-guide/workflows/how-tos/crm-automations/display-number-of-emails-received',
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_DISPLAY_RELATED_RECORD_DATA:
'/user-guide/workflows/how-tos/crm-automations/display-related-record-data',
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_FORMULA_FIELDS:
'/user-guide/workflows/how-tos/crm-automations/formula-fields',
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_NOTIFY_TEAMMATES_OF_NOTE_TO_REVIEW:
'/user-guide/workflows/how-tos/crm-automations/notify-teammates-of-note-to-review',
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_SEND_EMAIL_ALERTS_WITH_TASKS_DUE:
'/user-guide/workflows/how-tos/crm-automations/send-email-alerts-with-tasks-due',
USER_GUIDE_WORKFLOWS_HOW_TOS_NEED_MORE_HELP_PROFESSIONAL_SERVICES:
'/user-guide/workflows/how-tos/need-more-help/professional-services',
USER_GUIDE_WORKFLOWS_HOW_TOS_NEED_MORE_HELP_WORKFLOW_TROUBLESHOOTING:
'/user-guide/workflows/how-tos/need-more-help/workflow-troubleshooting',
USER_GUIDE_WORKFLOWS_HOW_TOS_NEED_MORE_HELP_WORKFLOWS_FAQ:
'/user-guide/workflows/how-tos/need-more-help/workflows-faq',
USER_GUIDE_WORKFLOWS_OVERVIEW: '/user-guide/workflows/overview',
} as const;
export type DocumentationPath =
(typeof DOCUMENTATION_PATHS)[keyof typeof DOCUMENTATION_PATHS];
@@ -0,0 +1,21 @@
import { DOCUMENTATION_DEFAULT_LANGUAGE } from './DocumentationDefaultLanguage';
export const DOCUMENTATION_SUPPORTED_LANGUAGES = [
DOCUMENTATION_DEFAULT_LANGUAGE,
'fr',
'ar',
'cs',
'de',
'es',
'it',
'ja',
'ko',
'pt',
'ro',
'ru',
'tr',
'zh',
] as const;
export type DocumentationSupportedLanguage =
(typeof DOCUMENTATION_SUPPORTED_LANGUAGES)[number];
@@ -14,6 +14,13 @@ export { CURRENCY_CODE_LABELS } from './CurrencyCodeLabels';
export { DATE_TYPE_FORMAT } from './DateTypeFormat';
export { DEFAULT_NUMBER_OF_GROUPS_LIMIT } from './DefaultNumberOfGroupsLimit';
export { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from './DefaultRelativeDateFilterValue';
export { DOCUMENTATION_BASE_URL } from './DocumentationBaseUrl';
export { DOCUMENTATION_DEFAULT_LANGUAGE } from './DocumentationDefaultLanguage';
export { DOCUMENTATION_DEFAULT_PATH } from './DocumentationDefaultPath';
export type { DocumentationPath } from './DocumentationPaths';
export { DOCUMENTATION_PATHS } from './DocumentationPaths';
export type { DocumentationSupportedLanguage } from './DocumentationSupportedLanguages';
export { DOCUMENTATION_SUPPORTED_LANGUAGES } from './DocumentationSupportedLanguages';
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
@@ -1,4 +1,7 @@
import { APP_LOCALES, type AppLocale } from '@/translations/constants/AppLocales';
import {
APP_LOCALES,
type AppLocale,
} from '@/translations/constants/AppLocales';
export const isValidLocale = (value: string | null): value is AppLocale =>
value !== null && value in APP_LOCALES;
@@ -12,10 +12,6 @@ describe('variable path utility functions', () => {
expect(needsEscaping('toto toto')).toBe(true);
});
it('should return true for keys with dots', () => {
expect(needsEscaping('key.with.dots')).toBe(true);
});
it('should return true for keys with brackets', () => {
expect(needsEscaping('key[0]')).toBe(true);
expect(needsEscaping('[key]')).toBe(true);
@@ -33,10 +29,6 @@ describe('variable path utility functions', () => {
expect(escapePathSegment('key with space')).toBe('[key with space]');
});
it('should wrap keys with dots in brackets', () => {
expect(escapePathSegment('key.with.dots')).toBe('[key.with.dots]');
});
it('should not modify simple keys', () => {
expect(escapePathSegment('simpleKey')).toBe('simpleKey');
});
@@ -55,12 +47,6 @@ describe('variable path utility functions', () => {
);
});
it('should escape segments with dots', () => {
expect(joinVariablePath(['step', 'key.with.dots'])).toBe(
'step.[key.with.dots]',
);
});
it('should handle mixed simple and special segments', () => {
expect(
joinVariablePath(['step', 'normal', 'has space', 'another']),
@@ -94,13 +80,6 @@ describe('variable path utility functions', () => {
]);
});
it('should parse path with bracketed segments containing dots', () => {
expect(parseVariablePath('step.[key.with.dots]')).toEqual([
'step',
'key.with.dots',
]);
});
it('should handle multiple bracketed segments', () => {
expect(
parseVariablePath('[first key].[second key].[third key]'),
@@ -131,7 +110,6 @@ describe('variable path utility functions', () => {
const paths = [
['step', 'field', 'value'],
['step', 'key with space', 'value'],
['step', 'key.with.dots'],
['step', 'normal', 'has space', 'another'],
];
@@ -1,6 +1,5 @@
// Characters that require bracket escaping in variable paths
// Spaces, dots, and brackets would break the dot-notation parsing
const SPECIAL_CHARS_REGEX = /[\s.[]/;
const SPECIAL_CHARS_REGEX = /[\s[]/;
export const needsEscaping = (key: string): boolean =>
SPECIAL_CHARS_REGEX.test(key);
+1
View File
@@ -57297,6 +57297,7 @@ __metadata:
resolution: "twenty-docs@workspace:packages/twenty-docs"
dependencies:
mintlify: "npm:latest"
twenty-shared: "workspace:*"
languageName: unknown
linkType: soft